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) } } }