Files
Cyrene_v2/internal/config/runtimeconfig/plugins_test.go
T
AskaEth 9654ce8861 feat(plugin): MCP 服务器管理页 + 按 server 独立启停与状态快照
design/09 §5.2.1 / §5.6.1:补上「看不见连没连上 / 暴露了哪些工具 / 为什么挂了」的洞。

- runtimeconfig:PluginConfig 新增 `enabled`(*bool,缺省=启用 ⇒ 既有 plugins.json
  向后兼容);新增 RedactPluginSecrets(整把凭据 + 任意 ≥6 字符片段一并抹除)。
- mcp:新增 status.go 的只读状态读面(ToolSummaries:工具名 + 截断描述,取自能力
  注册表本身,因此与模型可见的工具集合同源)。
- plugin 管理器:
  · Install/Restore 尊重 enabled(停用=落盘不连接,重启后仍是停用,且一次都不连);
  · SetEnabled 落盘 enabled(停用先落盘再下线;启用失败时 enabled 保持 false);
  · 新增 Reconnect(对已停用者拒绝,不替用户启用);
  · 新增 Statuses(三态:connected 缺席=未知,工具数/明细/时间只在知道时给出,
    lastError 在记录那一刻即脱敏截断);
  · 内置「MCP 服务器」页声明 + 7 个已声明动作(list/install/reconnect/enable/
    disable/uninstall/transports),走既有通用动作端点(owner-admin + 审计);
  · 护栏:热装插件的生命周期不改动内置提供者(mcp)的目录条目。
- gateway:已安装列表与 install 响应加 enabled,install 支持 config.enabled。
2026-10-24 19:31:31 +08:00

250 lines
7.6 KiB
Go
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.
package runtimeconfig
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestLoadPlugins_MissingFileIsEmpty(t *testing.T) {
t.Parallel()
got, err := LoadPlugins(filepath.Join(t.TempDir(), "plugins.json"))
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if len(got) != 0 {
t.Fatalf("len = %d, want 0", len(got))
}
}
func TestSaveLoadPlugins_RoundTrip(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "nested", "plugins.json")
in := []PluginConfig{{
ID: "p1",
Name: "calc",
Kind: PluginKindMCP,
Transport: TransportHTTP,
Endpoint: "http://127.0.0.1:3001/mcp",
APIKey: "sk-secret-1234",
CreatedAt: "2026-09-08T00:00:00Z",
}}
if err := SavePlugins(path, in); err != nil {
t.Fatalf("SavePlugins: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if runtime.GOOS != "windows" {
// Windows has no POSIX permission bits for regular files: Chmod only
// toggles the read-only flag and Stat reports 0666 regardless of the
// requested 0600, so this assertion is only meaningful on Unix-like
// platforms. SavePlugins still requests 0600 (see plugins.go).
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("mode = %o, want 600", perm)
}
}
got, err := LoadPlugins(path)
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if len(got) != 1 || got[0].Name != "calc" || got[0].APIKey != "sk-secret-1234" {
t.Fatalf("got = %+v", got)
}
}
func TestLoadPlugins_EmptyArrayIsValid(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
if err := os.WriteFile(path, []byte("[]"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
got, err := LoadPlugins(path)
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if len(got) != 0 {
t.Fatalf("len = %d, want 0", len(got))
}
}
func TestLoadPlugins_InvalidReturnsError(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
if err := os.WriteFile(path, []byte(`[{"name":""}]`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := LoadPlugins(path); !errors.Is(err, ErrInvalidPlugin) {
t.Fatalf("err = %v, want ErrInvalidPlugin", err)
}
}
func TestValidatePlugin(t *testing.T) {
t.Parallel()
cases := []struct {
name string
cfg PluginConfig
wantErr bool
}{
{name: "stdio ok", cfg: PluginConfig{Name: "a", Command: "mcp-a"}},
{name: "http ok", cfg: PluginConfig{Name: "b", Endpoint: "http://x/mcp"}},
{name: "sse ok", cfg: PluginConfig{Name: "c", Transport: TransportSSE, Endpoint: "http://x/sse"}},
{name: "blank name", cfg: PluginConfig{Command: "x"}, wantErr: true},
{name: "bad kind", cfg: PluginConfig{Name: "d", Kind: "tool", Command: "x"}, wantErr: true},
{name: "stdio no command", cfg: PluginConfig{Name: "e", Transport: TransportStdio}, wantErr: true},
{name: "http no endpoint", cfg: PluginConfig{Name: "f", Transport: TransportHTTP}, wantErr: true},
{name: "bad transport", cfg: PluginConfig{Name: "g", Transport: "carrier-pigeon", Endpoint: "http://x"}, wantErr: true},
{name: "no transport no fields", cfg: PluginConfig{Name: "h"}, wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := tc.cfg.Validate()
if tc.wantErr && err == nil {
t.Fatal("want error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestUpsertPlugin_ByNameReplacesAndPreservesOrder(t *testing.T) {
t.Parallel()
base := []PluginConfig{
{ID: "1", Name: "a", Command: "a"},
{ID: "2", Name: "b", Command: "b"},
}
got := UpsertPlugin(base, PluginConfig{Name: "b", Command: "b2"})
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got[1].Name != "b" || got[1].Command != "b2" {
t.Fatalf("got[1] = %+v", got[1])
}
got = UpsertPlugin(got, PluginConfig{Name: "c", Command: "c"})
if len(got) != 3 || got[2].Name != "c" || got[2].ID == "" {
t.Fatalf("append = %+v", got)
}
}
func TestDeletePlugin(t *testing.T) {
t.Parallel()
base := []PluginConfig{{ID: "1", Name: "a", Command: "a"}, {ID: "2", Name: "b", Command: "b"}}
got, removed := DeletePlugin(base, "a")
if !removed || len(got) != 1 || got[0].Name != "b" {
t.Fatalf("removed=%v got=%+v", removed, got)
}
got, removed = DeletePlugin(base, "2")
if !removed || len(got) != 1 || got[0].Name != "a" {
t.Fatalf("by id: removed=%v got=%+v", removed, got)
}
if _, removed := DeletePlugin(base, "nope"); removed {
t.Fatal("removed = true for unknown name")
}
}
func TestMaskedPlugin_EmptySecretsStayEmpty(t *testing.T) {
t.Parallel()
got := MaskedPlugin(PluginConfig{Name: "calc", Env: map[string]string{"API_KEY": ""}})
if got.APIKey != "" {
t.Fatalf("apiKey = %q, want empty", got.APIKey)
}
if got.Env["API_KEY"] != "" {
t.Fatalf("empty env secret = %q, want empty", got.Env["API_KEY"])
}
}
func TestMaskedPlugin_RedactsKeyAndEnv(t *testing.T) {
t.Parallel()
p := PluginConfig{
Name: "calc",
APIKey: "sk-secret-1234",
Env: map[string]string{"PATH": "/usr/bin", "GITHUB_TOKEN": "ghp-abcdefgh"},
}
got := MaskedPlugin(p)
if got.APIKey != "****1234" {
t.Fatalf("apiKey = %q, want ****1234", got.APIKey)
}
if got.Env["PATH"] != "/usr/bin" {
t.Fatalf("PATH = %q, want unchanged", got.Env["PATH"])
}
if got.Env["GITHUB_TOKEN"] != "****efgh" {
t.Fatalf("GITHUB_TOKEN = %q, want masked", got.Env["GITHUB_TOKEN"])
}
if p.APIKey != "sk-secret-1234" {
t.Fatal("original mutated")
}
raw, _ := json.Marshal(got)
if strings.Contains(string(raw), "sk-secret-1234") || strings.Contains(string(raw), "ghp-abcdefgh") {
t.Fatalf("masked JSON leaks a secret: %s", raw)
}
}
// TestPluginEnabled_BackwardCompatible 锁定 enabled 的向后兼容:既有 plugins.json
// (没有 enabled 键)必须被当成**启用**,而显式 false 必须原样存活(含重启)。
func TestPluginEnabled_BackwardCompatible(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
legacy := `[{"name":"calc","kind":"mcp","transport":"http","endpoint":"https://x"}]`
if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil {
t.Fatalf("write legacy file: %v", err)
}
got, err := LoadPlugins(path)
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if len(got) != 1 || !got[0].IsEnabled() {
t.Fatalf("老文件(无 enabled 键)应视为启用:%+v", got)
}
if got[0].Enabled != nil {
t.Fatalf("缺失的 enabled 必须保持 nil(= 缺席),得到 %v", *got[0].Enabled)
}
off := got[0].WithEnabled(false)
if err := SavePlugins(path, []PluginConfig{off}); err != nil {
t.Fatalf("SavePlugins: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if !strings.Contains(string(raw), `"enabled": false`) {
t.Fatalf("显式停用必须落盘:%s", raw)
}
back, err := LoadPlugins(path)
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if back[0].IsEnabled() {
t.Fatalf("重启后停用态丢失:%+v", back[0])
}
}
// TestRedactPluginSecrets_ScrubsFragments 锁定「凭据任意片段也不许泄漏」。
func TestRedactPluginSecrets_ScrubsFragments(t *testing.T) {
t.Parallel()
const key = "sk-live-0123456789abcdef"
p := PluginConfig{APIKey: key, Env: map[string]string{"GH_TOKEN": "ghp_secret_value"}}
for _, text := range []string{
"bearer " + key,
"echo " + key[8:20],
"tail " + key[len(key)-6:],
"env GH_TOKEN=ghp_secret_value",
} {
out := RedactPluginSecrets(p, text)
if strings.Contains(out, key) || strings.Contains(out, "ghp_secret_value") {
t.Fatalf("整把凭据仍在:%q", out)
}
if strings.Contains(out, key[8:20]) || strings.Contains(out, key[len(key)-6:]) {
t.Fatalf("凭据片段仍在:%q", out)
}
}
}