802 lines
25 KiB
Go
802 lines
25 KiB
Go
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 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: `<img src=x onerror=alert(1)>`,
|
|
Type: pluginapi.ConfigFieldTypeEnum,
|
|
EnumValues: []string{`<fast>`, `safe & sound`},
|
|
Description: `"quoted" 'single' <b>mode</b>`,
|
|
}})
|
|
if len(fields) != 1 {
|
|
t.Fatalf("fields len = %d, want 1", len(fields))
|
|
}
|
|
if fields[0].Name != html.EscapeString(`<img src=x onerror=alert(1)>`) {
|
|
t.Fatalf("field name = %q, want escaped", fields[0].Name)
|
|
}
|
|
if fields[0].EnumValues[0] != html.EscapeString(`<fast>`) || 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' <b>mode</b>`) {
|
|
t.Fatalf("description = %q, want escaped", fields[0].Description)
|
|
}
|
|
|
|
menus := pluginMenus([]pluginhost.RegisteredPluginMenu{{
|
|
Path: `/v0/resource/plugins/sample/<status>`,
|
|
Menu: `<b>Status</b>`,
|
|
Description: `Shows <script>alert(1)</script>.`,
|
|
}})
|
|
if len(menus) != 1 {
|
|
t.Fatalf("menus len = %d, want 1", len(menus))
|
|
}
|
|
if menus[0].Path != html.EscapeString(`/v0/resource/plugins/sample/<status>`) ||
|
|
menus[0].Menu != html.EscapeString(`<b>Status</b>`) ||
|
|
menus[0].Description != html.EscapeString(`Shows <script>alert(1)</script>.`) {
|
|
t.Fatalf("menu = %#v, want escaped strings", menus[0])
|
|
}
|
|
|
|
meta := pluginMetadata(pluginapi.Metadata{
|
|
Name: `<script>alert(1)</script>`,
|
|
Version: `1.0.0&evil=true`,
|
|
Author: `"attacker"`,
|
|
GitHubRepository: `https://example.com/repo?x=<script>`,
|
|
Logo: `<svg onload=alert(1)>`,
|
|
})
|
|
if meta.Name != html.EscapeString(`<script>alert(1)</script>`) ||
|
|
meta.Version != html.EscapeString(`1.0.0&evil=true`) ||
|
|
meta.Author != html.EscapeString(`"attacker"`) ||
|
|
meta.GitHubRepository != html.EscapeString(`https://example.com/repo?x=<script>`) ||
|
|
meta.Logo != html.EscapeString(`<svg onload=alert(1)>`) {
|
|
t.Fatalf("metadata = %#v, want escaped strings", meta)
|
|
}
|
|
}
|
|
|
|
func writeManagementPluginFile(t *testing.T, id string) string {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH)
|
|
if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil {
|
|
t.Fatalf("MkdirAll() error = %v", errMkdirAll)
|
|
}
|
|
path := filepath.Join(archDir, id+managementPluginExtension(runtime.GOOS))
|
|
if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {
|
|
t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)
|
|
}
|
|
return root
|
|
}
|
|
|
|
func managementPluginExtension(goos string) string {
|
|
switch goos {
|
|
case "darwin":
|
|
return ".dylib"
|
|
case "windows":
|
|
return ".dll"
|
|
default:
|
|
return ".so"
|
|
}
|
|
}
|
|
|
|
func pluginConfigFromYAML(t *testing.T, text string) config.PluginInstanceConfig {
|
|
t.Helper()
|
|
var item config.PluginInstanceConfig
|
|
if errUnmarshal := yaml.Unmarshal([]byte(text), &item); errUnmarshal != nil {
|
|
t.Fatalf("unmarshal plugin config: %v", errUnmarshal)
|
|
}
|
|
return item
|
|
}
|
|
|
|
func marshalPluginRaw(t *testing.T, item config.PluginInstanceConfig) string {
|
|
t.Helper()
|
|
data, errMarshal := yaml.Marshal(&item.Raw)
|
|
if errMarshal != nil {
|
|
t.Fatalf("marshal plugin raw: %v", errMarshal)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func pluginRawScalarValue(t *testing.T, item config.PluginInstanceConfig, key string) string {
|
|
t.Helper()
|
|
for i := 0; i+1 < len(item.Raw.Content); i += 2 {
|
|
if item.Raw.Content[i] != nil && item.Raw.Content[i].Value == key && item.Raw.Content[i+1] != nil {
|
|
return item.Raw.Content[i+1].Value
|
|
}
|
|
}
|
|
t.Fatalf("plugin raw missing scalar key %q", key)
|
|
return ""
|
|
}
|
|
|
|
func setPluginRawScalarValue(t *testing.T, node *yaml.Node, key, value string) {
|
|
t.Helper()
|
|
for i := 0; i+1 < len(node.Content); i += 2 {
|
|
if node.Content[i] != nil && node.Content[i].Value == key && node.Content[i+1] != nil {
|
|
node.Content[i+1].Value = value
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("plugin raw missing scalar key %q", key)
|
|
}
|