package management import ( "bytes" "context" "encoding/json" "html" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "strings" "testing" "time" "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" "gopkg.in/yaml.v3" ) func waitForAsyncReload(t *testing.T, reloads <-chan *config.Config) *config.Config { t.Helper() select { case cfg := <-reloads: return cfg case <-time.After(time.Second): t.Fatal("timed out waiting for async config reload") return nil } } func waitForReloadDone(t *testing.T, done <-chan struct{}) { t.Helper() select { case <-done: case <-time.After(time.Second): t.Fatal("timed out waiting for config reload hook to finish") } } func captureConfigReload(h *Handler) (<-chan *config.Config, <-chan struct{}) { reloads := make(chan *config.Config, 1) done := make(chan struct{}) h.SetConfigReloadHook(func(_ context.Context, cfg *config.Config) { defer close(done) reloads <- cfg }) return reloads, done } func TestConfigReloadGenerationSkipsOlderSnapshot(t *testing.T) { t.Parallel() h := &Handler{ cfg: &config.Config{ Plugins: config.PluginsConfig{ Configs: map[string]config.PluginInstanceConfig{ "sample": pluginConfigFromYAML(t, "enabled: true\nmode: old\n"), }, }, }, } reloadedModes := make([]string, 0, 1) h.SetConfigReloadHook(func(_ context.Context, cfg *config.Config) { reloadedModes = append(reloadedModes, pluginRawScalarValue(t, cfg.Plugins.Configs["sample"], "mode")) }) h.mu.Lock() older := h.reloadSnapshotConfigLocked() item := h.cfg.Plugins.Configs["sample"] setPluginRawScalarValue(t, &item.Raw, "mode", "new") h.cfg.Plugins.Configs["sample"] = item newer := h.reloadSnapshotConfigLocked() h.mu.Unlock() h.reloadConfigAfterManagementSave(context.Background(), newer) h.reloadConfigAfterManagementSave(context.Background(), older) if len(reloadedModes) != 1 || reloadedModes[0] != "new" { t.Fatalf("reloaded modes = %#v, want only new snapshot", reloadedModes) } } func TestListPluginsIncludesScannedAndConfiguredPlugins(t *testing.T) { t.Parallel() pluginsDir := writeManagementPluginFile(t, "scanned") disabled := false h := &Handler{ cfg: &config.Config{ Plugins: config.PluginsConfig{ Enabled: false, Dir: pluginsDir, Configs: map[string]config.PluginInstanceConfig{ "configured-only": {Enabled: &disabled}, }, }, }, configFilePath: writeTestConfigFile(t), } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugins", nil) h.ListPlugins(c) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var body struct { PluginsEnabled bool `json:"plugins_enabled"` Plugins []struct { ID string `json:"id"` Path string `json:"path"` Configured bool `json:"configured"` Registered bool `json:"registered"` Enabled bool `json:"enabled"` EffectiveEnabled bool `json:"effective_enabled"` SupportsOAuth bool `json:"supports_oauth"` OAuthProvider string `json:"oauth_provider"` Logo string `json:"logo"` ConfigFields []any `json:"config_fields"` Menus []any `json:"menus"` } `json:"plugins"` } if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { t.Fatalf("decode response: %v; body=%s", errDecode, rec.Body.String()) } if body.PluginsEnabled { t.Fatal("plugins_enabled = true, want false") } entries := map[string]struct { Configured bool Registered bool Enabled bool EffectiveEnabled bool Path string }{} for _, item := range body.Plugins { entries[item.ID] = struct { Configured bool Registered bool Enabled bool EffectiveEnabled bool Path string }{ Configured: item.Configured, Registered: item.Registered, Enabled: item.Enabled, EffectiveEnabled: item.EffectiveEnabled, Path: item.Path, } if item.Registered || item.SupportsOAuth || item.OAuthProvider != "" || item.Logo != "" || len(item.ConfigFields) != 0 || len(item.Menus) != 0 { t.Fatalf("unregistered plugin entry has runtime fields: %#v", item) } } if got, ok := entries["scanned"]; !ok || got.Configured || got.Enabled || got.EffectiveEnabled || got.Path == "" { t.Fatalf("scanned entry = %#v, exists=%v", got, ok) } if got, ok := entries["configured-only"]; !ok || !got.Configured || got.Enabled || got.EffectiveEnabled || got.Path != "" { t.Fatalf("configured-only entry = %#v, exists=%v", got, ok) } } func TestListPluginsUsesConfiguredStoreVersionWhenFilesCoexist(t *testing.T) { t.Parallel() pluginsDir := t.TempDir() archDir := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH) if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil { t.Fatalf("MkdirAll(%s) error = %v", archDir, errMkdirAll) } extension := managementPluginExtension(runtime.GOOS) pinnedPath := filepath.Join(archDir, "sample-provider-v0.1.0"+extension) newerPath := filepath.Join(archDir, "sample-provider-v0.2.0"+extension) for _, path := range []string{pinnedPath, newerPath} { if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil { t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile) } } h := &Handler{ cfg: &config.Config{ Plugins: config.PluginsConfig{ Enabled: true, Dir: pluginsDir, Configs: map[string]config.PluginInstanceConfig{ "sample-provider": pluginConfigFromYAML(t, "enabled: true\nstore:\n version: 0.1.0\n"), }, }, }, configFilePath: writeTestConfigFile(t), } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugins", nil) h.ListPlugins(c) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var body pluginListResponse if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { t.Fatalf("decode response: %v; body=%s", errDecode, rec.Body.String()) } for _, entry := range body.Plugins { if entry.ID != "sample-provider" { continue } if entry.Path != pinnedPath || !entry.Configured || !entry.Enabled { t.Fatalf("plugin entry = %#v, want pinned path %s", entry, pinnedPath) } return } t.Fatalf("sample-provider entry missing: %#v", body.Plugins) } func TestGetPluginConfigReturnsPreservedRawConfig(t *testing.T) { t.Parallel() h := &Handler{ cfg: &config.Config{ Plugins: config.PluginsConfig{ Configs: map[string]config.PluginInstanceConfig{ "sample": pluginConfigFromYAML(t, ` enabled: false priority: 7 mode: safe allowed_models: - gemini-2.5-pro - claude-sonnet-4 options: retries: 2 strict: true `), }, }, }, configFilePath: writeTestConfigFile(t), } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Params = gin.Params{{Key: "id", Value: "sample"}} c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugins/sample/config", nil) h.GetPluginConfig(c) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var body struct { Enabled bool `json:"enabled"` Priority int `json:"priority"` Mode string `json:"mode"` AllowedModels []string `json:"allowed_models"` Options map[string]any `json:"options"` } if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { t.Fatalf("decode response: %v; body=%s", errDecode, rec.Body.String()) } if body.Enabled || body.Priority != 7 || body.Mode != "safe" { t.Fatalf("base fields = enabled %v priority %d mode %q, want false 7 safe", body.Enabled, body.Priority, body.Mode) } if len(body.AllowedModels) != 2 || body.AllowedModels[0] != "gemini-2.5-pro" || body.AllowedModels[1] != "claude-sonnet-4" { t.Fatalf("allowed_models = %#v", body.AllowedModels) } if body.Options["retries"] != float64(2) || body.Options["strict"] != true { t.Fatalf("options = %#v", body.Options) } } func TestGetPluginConfigReturnsEmptyObjectForKnownUnconfiguredPlugin(t *testing.T) { t.Parallel() pluginsDir := writeManagementPluginFile(t, "scanned") h := &Handler{ cfg: &config.Config{ Plugins: config.PluginsConfig{ Dir: pluginsDir, }, }, configFilePath: writeTestConfigFile(t), } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Params = gin.Params{{Key: "id", Value: "scanned"}} c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugins/scanned/config", nil) h.GetPluginConfig(c) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var body map[string]any if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { t.Fatalf("decode response: %v; body=%s", errDecode, rec.Body.String()) } if len(body) != 0 { t.Fatalf("body = %#v, want empty object", body) } } func TestGetPluginConfigReturnsNotFoundForUnknownPlugin(t *testing.T) { t.Parallel() h := &Handler{ cfg: &config.Config{}, configFilePath: writeTestConfigFile(t), } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Params = gin.Params{{Key: "id", Value: "missing"}} c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugins/missing/config", nil) h.GetPluginConfig(c) if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNotFound, rec.Body.String()) } } func TestPatchPluginEnabledUpdatesOnlyPluginConfig(t *testing.T) { t.Parallel() h := &Handler{ cfg: &config.Config{ Plugins: config.PluginsConfig{ Enabled: false, Configs: map[string]config.PluginInstanceConfig{ "sample": pluginConfigFromYAML(t, "enabled: false\npriority: 2\nmode: safe\n"), }, }, }, configFilePath: writeTestConfigFile(t), } reloads, reloadDone := captureConfigReload(h) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Params = gin.Params{{Key: "id", Value: "sample"}} c.Request = httptest.NewRequest(http.MethodPatch, "/v0/management/plugins/sample/enabled", strings.NewReader(`{"enabled":true}`)) c.Request.Header.Set("Content-Type", "application/json") h.PatchPluginEnabled(c) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } cfgSnapshot := waitForAsyncReload(t, reloads) waitForReloadDone(t, reloadDone) if cfgSnapshot == h.cfg { t.Fatalf("reload config = handler config %p, want independent snapshot", h.cfg) } if cfgSnapshot.Plugins.Enabled { t.Fatal("snapshot global Plugins.Enabled changed to true") } snapshotItem := cfgSnapshot.Plugins.Configs["sample"] if snapshotItem.Enabled == nil || !*snapshotItem.Enabled { t.Fatalf("snapshot sample enabled = %#v, want true", snapshotItem.Enabled) } if raw := marshalPluginRaw(t, snapshotItem); !strings.Contains(raw, "mode: safe") { t.Fatalf("snapshot raw config lost custom field:\n%s", raw) } if h.cfg.Plugins.Enabled { t.Fatal("global Plugins.Enabled changed to true") } item := h.cfg.Plugins.Configs["sample"] if item.Enabled == nil || !*item.Enabled { t.Fatalf("sample enabled = %#v, want true", item.Enabled) } raw := marshalPluginRaw(t, item) if !strings.Contains(raw, "mode: safe") { t.Fatalf("raw config lost custom field:\n%s", raw) } } func TestPatchPluginEnabledReloadSnapshotRawImmutability(t *testing.T) { t.Parallel() h := &Handler{ cfg: &config.Config{ Plugins: config.PluginsConfig{ Configs: map[string]config.PluginInstanceConfig{ "sample": pluginConfigFromYAML(t, "enabled: false\nmode: first\n"), }, }, }, configFilePath: writeTestConfigFile(t), } reloads := make(chan *config.Config, 1) releaseReload := make(chan struct{}) reloadDone := make(chan struct{}) h.SetConfigReloadHook(func(_ context.Context, cfg *config.Config) { defer close(reloadDone) reloads <- cfg <-releaseReload }) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Params = gin.Params{{Key: "id", Value: "sample"}} c.Request = httptest.NewRequest(http.MethodPatch, "/v0/management/plugins/sample/enabled", strings.NewReader(`{"enabled":true}`)) c.Request.Header.Set("Content-Type", "application/json") h.PatchPluginEnabled(c) if rec.Code != http.StatusOK { close(releaseReload) waitForReloadDone(t, reloadDone) t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } cfgSnapshot := waitForAsyncReload(t, reloads) h.mu.Lock() item := h.cfg.Plugins.Configs["sample"] setPluginRawScalarValue(t, &item.Raw, "mode", "second") h.cfg.Plugins.Configs["sample"] = item h.mu.Unlock() if cfgSnapshot == h.cfg { t.Fatalf("reload config = handler config %p, want independent snapshot", h.cfg) } snapshotItem := cfgSnapshot.Plugins.Configs["sample"] if snapshotItem.Enabled == nil || !*snapshotItem.Enabled { t.Fatalf("snapshot sample enabled = %#v, want true", snapshotItem.Enabled) } if got := pluginRawScalarValue(t, snapshotItem, "mode"); got != "first" { t.Fatalf("snapshot raw mode = %q, want first", got) } h.mu.Lock() handlerItem := h.cfg.Plugins.Configs["sample"] h.mu.Unlock() if got := pluginRawScalarValue(t, handlerItem, "mode"); got != "second" { t.Fatalf("handler raw mode = %q, want second", got) } close(releaseReload) waitForReloadDone(t, reloadDone) } func TestPutPluginConfigReplacesPluginConfig(t *testing.T) { t.Parallel() h := &Handler{ cfg: &config.Config{ Plugins: config.PluginsConfig{ Configs: map[string]config.PluginInstanceConfig{ "sample": pluginConfigFromYAML(t, "enabled: false\nmode: safe\nold: true\n"), }, }, }, configFilePath: writeTestConfigFile(t), } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Params = gin.Params{{Key: "id", Value: "sample"}} c.Request = httptest.NewRequest(http.MethodPut, "/v0/management/plugins/sample/config", bytes.NewBufferString(`{"enabled":true,"priority":7,"mode":"fast"}`)) c.Request.Header.Set("Content-Type", "application/json") h.PutPluginConfig(c) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } item := h.cfg.Plugins.Configs["sample"] if item.Enabled == nil || !*item.Enabled || item.Priority != 7 { t.Fatalf("plugin host fields = enabled %#v priority %d, want true priority 7", item.Enabled, item.Priority) } raw := marshalPluginRaw(t, item) if !strings.Contains(raw, "mode: fast") || strings.Contains(raw, "old:") { t.Fatalf("raw config =\n%s", raw) } } func TestPatchPluginConfigMergesAndDeletesFields(t *testing.T) { t.Parallel() h := &Handler{ cfg: &config.Config{ Plugins: config.PluginsConfig{ Configs: map[string]config.PluginInstanceConfig{ "sample": pluginConfigFromYAML(t, "enabled: false\npriority: 3\nmode: safe\nremove: yes\n"), }, }, }, configFilePath: writeTestConfigFile(t), } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Params = gin.Params{{Key: "id", Value: "sample"}} c.Request = httptest.NewRequest(http.MethodPatch, "/v0/management/plugins/sample/config", strings.NewReader(`{"mode":"fast","remove":null,"count":3}`)) c.Request.Header.Set("Content-Type", "application/json") h.PatchPluginConfig(c) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } item := h.cfg.Plugins.Configs["sample"] if item.Enabled == nil || *item.Enabled || item.Priority != 3 { t.Fatalf("plugin host fields = enabled %#v priority %d, want false priority 3", item.Enabled, item.Priority) } raw := marshalPluginRaw(t, item) if !strings.Contains(raw, "mode: fast") || !strings.Contains(raw, "count: 3") || strings.Contains(raw, "remove:") { t.Fatalf("raw config =\n%s", raw) } } func TestDeletePluginRejectsUnresolvedPluginsDir(t *testing.T) { workspace := t.TempDir() t.Setenv("HOME", "") t.Setenv("USERPROFILE", "") t.Chdir(workspace) literalPluginsDir := filepath.Join(workspace, "~", ".cli-proxy-api", "plugins") targetDir := filepath.Join(literalPluginsDir, runtime.GOOS, runtime.GOARCH) if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil { t.Fatalf("MkdirAll(%s) error = %v", targetDir, errMkdir) } target := filepath.Join(targetDir, "sample"+managementPluginExtension(runtime.GOOS)) if errWrite := os.WriteFile(target, []byte("library-data"), 0o644); errWrite != nil { t.Fatalf("WriteFile(%s) error = %v", target, errWrite) } h := &Handler{ cfg: &config.Config{ Plugins: config.PluginsConfig{ Dir: "~/.cli-proxy-api/plugins", Configs: map[string]config.PluginInstanceConfig{ "sample": pluginConfigFromYAML(t, "enabled: false\n"), }, }, }, configFilePath: writeTestConfigFile(t), } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Params = gin.Params{{Key: "id", Value: "sample"}} c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/plugins/sample", nil) h.DeletePlugin(c) if rec.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusInternalServerError, rec.Body.String()) } var body map[string]any if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String()) } if body["error"] != "plugin_directory_invalid" { t.Fatalf("error = %#v, want plugin_directory_invalid", body["error"]) } if _, errStat := os.Stat(target); errStat != nil { t.Fatalf("literal tilde target stat error = %v, want retained", errStat) } if _, configured := h.cfg.Plugins.Configs["sample"]; !configured { t.Fatal("plugin config removed after directory resolution failure") } } func TestDeletePluginRemovesDiscoveredFileAndConfig(t *testing.T) { t.Parallel() pluginsDir := writeManagementPluginFile(t, "sample") configPath := filepath.Join(t.TempDir(), "config.yaml") if errWrite := os.WriteFile(configPath, []byte("plugins:\n configs:\n sample:\n enabled: true\n mode: safe\n keep:\n enabled: true\n mode: retained\n"), 0o600); errWrite != nil { t.Fatalf("failed to write test config: %v", errWrite) } h := &Handler{ cfg: &config.Config{ Plugins: config.PluginsConfig{ Dir: pluginsDir, Configs: map[string]config.PluginInstanceConfig{ "sample": pluginConfigFromYAML(t, "enabled: true\nmode: safe\n"), "keep": pluginConfigFromYAML(t, "enabled: true\nmode: retained\n"), }, }, }, configFilePath: configPath, } reloads := make(chan *config.Config, 1) releaseReload := make(chan struct{}) reloadDone := make(chan struct{}) h.SetConfigReloadHook(func(_ context.Context, cfg *config.Config) { defer close(reloadDone) reloads <- cfg <-releaseReload }) path, errPath := pluginFilePath(pluginsDir, "sample") if errPath != nil { t.Fatalf("pluginFilePath() error = %v", errPath) } if path == "" { t.Fatal("plugin path is empty") } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Params = gin.Params{{Key: "id", Value: "sample"}} c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/plugins/sample", nil) done := make(chan struct{}) go func() { h.DeletePlugin(c) close(done) }() select { case <-done: case <-time.After(time.Second): t.Fatal("DeletePlugin blocked waiting for config reload") } if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if _, ok := h.cfg.Plugins.Configs["sample"]; ok { t.Fatal("plugin config still exists after delete") } if _, ok := h.cfg.Plugins.Configs["keep"]; !ok { t.Fatal("retained plugin config was removed") } data, errReadConfig := os.ReadFile(configPath) if errReadConfig != nil { t.Fatalf("failed to read saved config: %v", errReadConfig) } text := string(data) if strings.Contains(text, "sample:") || strings.Contains(text, "mode: safe") { t.Fatalf("saved config still contains removed plugin:\n%s", text) } if !strings.Contains(text, "keep:") || !strings.Contains(text, "mode: retained") { t.Fatalf("saved config lost retained plugin:\n%s", text) } if _, errStat := os.Stat(path); !os.IsNotExist(errStat) { t.Fatalf("plugin file stat error = %v, want not exist", errStat) } cfgSnapshot := waitForAsyncReload(t, reloads) if cfgSnapshot == h.cfg { close(releaseReload) waitForReloadDone(t, reloadDone) t.Fatalf("reload config = handler config %p, want independent snapshot", h.cfg) } if _, ok := cfgSnapshot.Plugins.Configs["sample"]; ok { close(releaseReload) waitForReloadDone(t, reloadDone) t.Fatal("snapshot plugin config still exists after delete") } close(releaseReload) waitForReloadDone(t, reloadDone) } func TestDeletePluginUsesConfiguredStoreVersionWhenFilesCoexist(t *testing.T) { t.Parallel() pluginsDir := t.TempDir() archDir := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH) if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil { t.Fatalf("MkdirAll(%s) error = %v", archDir, errMkdirAll) } extension := managementPluginExtension(runtime.GOOS) pinnedPath := filepath.Join(archDir, "sample-provider-v0.1.0"+extension) newerPath := filepath.Join(archDir, "sample-provider-v0.2.0"+extension) for _, path := range []string{pinnedPath, newerPath} { if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil { t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile) } } h := &Handler{ cfg: &config.Config{ Plugins: config.PluginsConfig{ Dir: pluginsDir, Configs: map[string]config.PluginInstanceConfig{ "sample-provider": pluginConfigFromYAML(t, "enabled: true\nstore:\n version: 0.1.0\n"), }, }, }, configFilePath: writeTestConfigFile(t), } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Params = gin.Params{{Key: "id", Value: "sample-provider"}} c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/plugins/sample-provider", nil) h.DeletePlugin(c) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if _, ok := h.cfg.Plugins.Configs["sample-provider"]; ok { t.Fatal("plugin config still exists after delete") } if _, errStat := os.Stat(pinnedPath); !os.IsNotExist(errStat) { t.Fatalf("pinned plugin stat error = %v, want not exist", errStat) } if _, errStat := os.Stat(newerPath); errStat != nil { t.Fatalf("newer plugin stat error = %v, want still exists", errStat) } } func TestDeletePluginReturnsNotFoundForUnknownPlugin(t *testing.T) { t.Parallel() h := &Handler{ cfg: &config.Config{}, configFilePath: writeTestConfigFile(t), } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Params = gin.Params{{Key: "id", Value: "missing"}} c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/plugins/missing", nil) h.DeletePlugin(c) if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNotFound, rec.Body.String()) } } func TestPluginDisplayFieldsEscapeHTML(t *testing.T) { t.Parallel() fields := pluginConfigFields([]pluginapi.ConfigField{{ Name: ``, Type: pluginapi.ConfigFieldTypeEnum, EnumValues: []string{``, `safe & sound`}, Description: `"quoted" 'single' mode`, }}) if len(fields) != 1 { t.Fatalf("fields len = %d, want 1", len(fields)) } if fields[0].Name != html.EscapeString(``) { t.Fatalf("field name = %q, want escaped", fields[0].Name) } if fields[0].EnumValues[0] != html.EscapeString(``) || fields[0].EnumValues[1] != html.EscapeString(`safe & sound`) { t.Fatalf("enum values = %#v, want escaped values", fields[0].EnumValues) } if fields[0].Description != html.EscapeString(`"quoted" 'single' mode`) { t.Fatalf("description = %q, want escaped", fields[0].Description) } menus := pluginMenus([]pluginhost.RegisteredPluginMenu{{ Path: `/v0/resource/plugins/sample/`, Menu: `Status`, Description: `Shows .`, }}) if len(menus) != 1 { t.Fatalf("menus len = %d, want 1", len(menus)) } if menus[0].Path != html.EscapeString(`/v0/resource/plugins/sample/`) || menus[0].Menu != html.EscapeString(`Status`) || menus[0].Description != html.EscapeString(`Shows .`) { t.Fatalf("menu = %#v, want escaped strings", menus[0]) } meta := pluginMetadata(pluginapi.Metadata{ Name: ``, Version: `1.0.0&evil=true`, Author: `"attacker"`, GitHubRepository: `https://example.com/repo?x=`) || meta.Version != html.EscapeString(`1.0.0&evil=true`) || meta.Author != html.EscapeString(`"attacker"`) || meta.GitHubRepository != html.EscapeString(`https://example.com/repo?x=