mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-08 14:01:10 -05:00
* feat(plugins): add JSONForms schema for plugin configuration Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance error handling by formatting validation errors with field names Signed-off-by: Deluan <deluan@navidrome.org> * feat: enforce required fields in config validation and improve error handling Signed-off-by: Deluan <deluan@navidrome.org> * format JS code Signed-off-by: Deluan <deluan@navidrome.org> * feat: add config schema validation and enhance manifest structure Signed-off-by: Deluan <deluan@navidrome.org> * feat: refactor plugin config parsing and add unit tests Signed-off-by: Deluan <deluan@navidrome.org> * feat: add config validation error message in Portuguese * feat: enhance AlwaysExpandedArrayLayout with description support and improve array control testing Signed-off-by: Deluan <deluan@navidrome.org> * feat: update Discord Rust plugin configuration to use JSONForm for user tokens and enhance schema validation Signed-off-by: Deluan <deluan@navidrome.org> * fix: resolve React Hooks linting issues in plugin UI components * Apply suggestions from code review Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * format code Signed-off-by: Deluan <deluan@navidrome.org> * feat: migrate schema validation to use santhosh-tekuri/jsonschema and improve error formatting Signed-off-by: Deluan <deluan@navidrome.org> * address PR comments Signed-off-by: Deluan <deluan@navidrome.org> * fix flaky test Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance array layout and configuration handling with AJV defaults Signed-off-by: Deluan <deluan@navidrome.org> * feat: implement custom tester to exclude enum arrays from AlwaysExpandedArrayLayout Signed-off-by: Deluan <deluan@navidrome.org> * feat: add error boundary for schema rendering and improve error messages Signed-off-by: Deluan <deluan@navidrome.org> * feat: refine non-enum array control logic by utilizing JSONForms schema resolution Signed-off-by: Deluan <deluan@navidrome.org> * feat: add error styling to ToggleEnabledSwitch for disabled state Signed-off-by: Deluan <deluan@navidrome.org> * feat: adjust label positioning and styling in SchemaConfigEditor for improved layout Signed-off-by: Deluan <deluan@navidrome.org> * feat: implement outlined input controls renderers to replace custom fragile CSS Signed-off-by: Deluan <deluan@navidrome.org> * feat: remove margin from last form control inside array items for better spacing Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance AJV error handling to transform required errors for field-level validation Signed-off-by: Deluan <deluan@navidrome.org> * feat: set default value for User Tokens in manifest.json to improve user experience Signed-off-by: Deluan <deluan@navidrome.org> * format Signed-off-by: Deluan <deluan@navidrome.org> * feat: add margin to outlined input controls for improved spacing Signed-off-by: Deluan <deluan@navidrome.org> * feat: remove redundant margin rule for last form control in array items Signed-off-by: Deluan <deluan@navidrome.org> * feat: adjust font size of label elements in SchemaConfigEditor for improved readability Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
383 lines
11 KiB
Go
383 lines
11 KiB
Go
//go:build !windows
|
|
|
|
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// testConfigInput is the input for nd_test_config callback.
|
|
type testConfigInput struct {
|
|
Operation string `json:"operation"`
|
|
Key string `json:"key,omitempty"`
|
|
Prefix string `json:"prefix,omitempty"`
|
|
}
|
|
|
|
// testConfigOutput is the output from nd_test_config callback.
|
|
type testConfigOutput struct {
|
|
StringVal string `json:"string_val,omitempty"`
|
|
IntVal int64 `json:"int_val,omitempty"`
|
|
Keys []string `json:"keys,omitempty"`
|
|
Exists bool `json:"exists,omitempty"`
|
|
Error *string `json:"error,omitempty"`
|
|
}
|
|
|
|
// setupTestConfigPlugin sets up a test environment with the test-config plugin loaded.
|
|
// Returns a cleanup function and a helper to call the plugin's nd_test_config function.
|
|
func setupTestConfigPlugin(configJSON string) (*Manager, func(context.Context, testConfigInput) (*testConfigOutput, error)) {
|
|
tmpDir, err := os.MkdirTemp("", "config-test-*")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Copy the test-config plugin
|
|
srcPath := filepath.Join(testdataDir, "test-config"+PackageExtension)
|
|
destPath := filepath.Join(tmpDir, "test-config"+PackageExtension)
|
|
data, err := os.ReadFile(srcPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = os.WriteFile(destPath, data, 0600)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Compute SHA256 for the plugin
|
|
hash := sha256.Sum256(data)
|
|
hashHex := hex.EncodeToString(hash[:])
|
|
|
|
// Setup config
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.Plugins.Enabled = true
|
|
conf.Server.Plugins.Folder = tmpDir
|
|
conf.Server.Plugins.AutoReload = false
|
|
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
|
|
|
// Setup mock DataStore
|
|
mockPluginRepo := tests.CreateMockPluginRepo()
|
|
mockPluginRepo.Permitted = true
|
|
mockPluginRepo.SetData(model.Plugins{{
|
|
ID: "test-config",
|
|
Path: destPath,
|
|
SHA256: hashHex,
|
|
Enabled: true,
|
|
AllUsers: true,
|
|
Config: configJSON,
|
|
}})
|
|
dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo}
|
|
|
|
// Create and start manager
|
|
manager := &Manager{
|
|
plugins: make(map[string]*plugin),
|
|
ds: dataStore,
|
|
subsonicRouter: http.NotFoundHandler(),
|
|
}
|
|
err = manager.Start(GinkgoT().Context())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
DeferCleanup(func() {
|
|
_ = manager.Stop()
|
|
_ = os.RemoveAll(tmpDir)
|
|
})
|
|
|
|
// Helper to call test plugin's exported function
|
|
callTestConfig := func(ctx context.Context, input testConfigInput) (*testConfigOutput, error) {
|
|
manager.mu.RLock()
|
|
p := manager.plugins["test-config"]
|
|
manager.mu.RUnlock()
|
|
|
|
instance, err := p.instance(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer instance.Close(ctx)
|
|
|
|
inputBytes, _ := json.Marshal(input)
|
|
_, outputBytes, err := instance.Call("nd_test_config", inputBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var output testConfigOutput
|
|
if err := json.Unmarshal(outputBytes, &output); err != nil {
|
|
return nil, err
|
|
}
|
|
if output.Error != nil {
|
|
return nil, errors.New(*output.Error)
|
|
}
|
|
return &output, nil
|
|
}
|
|
|
|
return manager, callTestConfig
|
|
}
|
|
|
|
var _ = Describe("ConfigService", func() {
|
|
var service *configServiceImpl
|
|
var ctx context.Context
|
|
|
|
BeforeEach(func() {
|
|
ctx = context.Background()
|
|
})
|
|
|
|
Describe("newConfigService", func() {
|
|
It("creates service with provided config", func() {
|
|
config := map[string]string{"key1": "value1", "key2": "value2"}
|
|
service = newConfigService("test_plugin", config)
|
|
Expect(service.pluginName).To(Equal("test_plugin"))
|
|
Expect(service.config).To(Equal(config))
|
|
})
|
|
|
|
It("creates service with empty config when nil", func() {
|
|
service = newConfigService("test_plugin", nil)
|
|
Expect(service.config).ToNot(BeNil())
|
|
Expect(service.config).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Describe("Get", func() {
|
|
BeforeEach(func() {
|
|
service = newConfigService("test_plugin", map[string]string{
|
|
"api_key": "secret123",
|
|
"debug_mode": "true",
|
|
"max_items": "100",
|
|
})
|
|
})
|
|
|
|
It("returns value for existing key", func() {
|
|
value, exists := service.Get(ctx, "api_key")
|
|
Expect(exists).To(BeTrue())
|
|
Expect(value).To(Equal("secret123"))
|
|
})
|
|
|
|
It("returns not exists for missing key", func() {
|
|
value, exists := service.Get(ctx, "missing_key")
|
|
Expect(exists).To(BeFalse())
|
|
Expect(value).To(Equal(""))
|
|
})
|
|
})
|
|
|
|
Describe("GetInt", func() {
|
|
BeforeEach(func() {
|
|
service = newConfigService("test_plugin", map[string]string{
|
|
"max_items": "100",
|
|
"timeout": "30",
|
|
"negative": "-50",
|
|
"not_a_number": "abc",
|
|
"float": "3.14",
|
|
})
|
|
})
|
|
|
|
It("returns integer for valid numeric value", func() {
|
|
value, exists := service.GetInt(ctx, "max_items")
|
|
Expect(exists).To(BeTrue())
|
|
Expect(value).To(Equal(int64(100)))
|
|
})
|
|
|
|
It("returns negative integer", func() {
|
|
value, exists := service.GetInt(ctx, "negative")
|
|
Expect(exists).To(BeTrue())
|
|
Expect(value).To(Equal(int64(-50)))
|
|
})
|
|
|
|
It("returns not exists for non-numeric value", func() {
|
|
value, exists := service.GetInt(ctx, "not_a_number")
|
|
Expect(exists).To(BeFalse())
|
|
Expect(value).To(Equal(int64(0)))
|
|
})
|
|
|
|
It("returns not exists for float value", func() {
|
|
value, exists := service.GetInt(ctx, "float")
|
|
Expect(exists).To(BeFalse())
|
|
Expect(value).To(Equal(int64(0)))
|
|
})
|
|
|
|
It("returns not exists for missing key", func() {
|
|
value, exists := service.GetInt(ctx, "missing_key")
|
|
Expect(exists).To(BeFalse())
|
|
Expect(value).To(Equal(int64(0)))
|
|
})
|
|
})
|
|
|
|
Describe("Keys", func() {
|
|
BeforeEach(func() {
|
|
service = newConfigService("test_plugin", map[string]string{
|
|
"zebra": "z",
|
|
"apple": "a",
|
|
"banana": "b",
|
|
"user_alice": "token1",
|
|
"user_bob": "token2",
|
|
"user_charlie": "token3",
|
|
})
|
|
})
|
|
|
|
It("returns all keys in sorted order when prefix is empty", func() {
|
|
keys := service.Keys(ctx, "")
|
|
Expect(keys).To(Equal([]string{"apple", "banana", "user_alice", "user_bob", "user_charlie", "zebra"}))
|
|
})
|
|
|
|
It("returns only keys matching prefix", func() {
|
|
keys := service.Keys(ctx, "user_")
|
|
Expect(keys).To(Equal([]string{"user_alice", "user_bob", "user_charlie"}))
|
|
})
|
|
|
|
It("returns empty slice when no keys match prefix", func() {
|
|
keys := service.Keys(ctx, "nonexistent_")
|
|
Expect(keys).To(BeEmpty())
|
|
})
|
|
|
|
It("returns empty slice for empty config", func() {
|
|
service = newConfigService("test_plugin", nil)
|
|
keys := service.Keys(ctx, "")
|
|
Expect(keys).To(BeEmpty())
|
|
})
|
|
})
|
|
})
|
|
|
|
var _ = Describe("ConfigService Integration", Ordered, func() {
|
|
var (
|
|
manager *Manager
|
|
callTestConfig func(context.Context, testConfigInput) (*testConfigOutput, error)
|
|
)
|
|
|
|
BeforeAll(func() {
|
|
manager, callTestConfig = setupTestConfigPlugin(`{"api_key":"test_secret","max_retries":"5","timeout":"30"}`)
|
|
})
|
|
|
|
Describe("Plugin Loading", func() {
|
|
It("should load plugin without config permission", func() {
|
|
manager.mu.RLock()
|
|
p, ok := manager.plugins["test-config"]
|
|
manager.mu.RUnlock()
|
|
Expect(ok).To(BeTrue())
|
|
Expect(p.manifest.Name).To(Equal("Test Config Plugin"))
|
|
})
|
|
})
|
|
|
|
Describe("Config Operations via Plugin", func() {
|
|
It("should get string value", func() {
|
|
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
|
|
Operation: "get",
|
|
Key: "api_key",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(output.StringVal).To(Equal("test_secret"))
|
|
Expect(output.Exists).To(BeTrue())
|
|
})
|
|
|
|
It("should return not exists for missing key", func() {
|
|
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
|
|
Operation: "get",
|
|
Key: "nonexistent",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(output.Exists).To(BeFalse())
|
|
})
|
|
|
|
It("should get integer value", func() {
|
|
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
|
|
Operation: "get_int",
|
|
Key: "max_retries",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(output.IntVal).To(Equal(int64(5)))
|
|
Expect(output.Exists).To(BeTrue())
|
|
})
|
|
|
|
It("should return not exists for non-integer value", func() {
|
|
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
|
|
Operation: "get_int",
|
|
Key: "api_key",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(output.Exists).To(BeFalse())
|
|
})
|
|
|
|
It("should list all config keys with empty prefix", func() {
|
|
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
|
|
Operation: "list",
|
|
Prefix: "",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(output.Keys).To(ConsistOf("api_key", "max_retries", "timeout"))
|
|
})
|
|
|
|
It("should list config keys with prefix filter", func() {
|
|
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
|
|
Operation: "list",
|
|
Prefix: "max",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(output.Keys).To(ConsistOf("max_retries"))
|
|
})
|
|
})
|
|
})
|
|
|
|
var _ = Describe("Complex Config Values Integration", Ordered, func() {
|
|
var callTestConfig func(context.Context, testConfigInput) (*testConfigOutput, error)
|
|
|
|
BeforeAll(func() {
|
|
// Config with arrays and objects - these should be properly serialized as JSON strings
|
|
_, callTestConfig = setupTestConfigPlugin(`{"api_key":"secret123","users":[{"username":"admin","token":"tok1"},{"username":"user2","token":"tok2"}],"settings":{"enabled":true,"count":5}}`)
|
|
})
|
|
|
|
Describe("Config Serialization", func() {
|
|
It("should make simple string config values accessible to plugin", func() {
|
|
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
|
|
Operation: "get",
|
|
Key: "api_key",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(output.Exists).To(BeTrue())
|
|
Expect(output.StringVal).To(Equal("secret123"))
|
|
})
|
|
|
|
It("should serialize array config values as JSON strings", func() {
|
|
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
|
|
Operation: "get",
|
|
Key: "users",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(output.Exists).To(BeTrue())
|
|
// Array values are serialized as JSON strings - parse to verify structure
|
|
var users []map[string]string
|
|
Expect(json.Unmarshal([]byte(output.StringVal), &users)).To(Succeed())
|
|
Expect(users).To(HaveLen(2))
|
|
Expect(users[0]).To(HaveKeyWithValue("username", "admin"))
|
|
Expect(users[0]).To(HaveKeyWithValue("token", "tok1"))
|
|
Expect(users[1]).To(HaveKeyWithValue("username", "user2"))
|
|
Expect(users[1]).To(HaveKeyWithValue("token", "tok2"))
|
|
})
|
|
|
|
It("should serialize object config values as JSON strings", func() {
|
|
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
|
|
Operation: "get",
|
|
Key: "settings",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(output.Exists).To(BeTrue())
|
|
// Object values are serialized as JSON strings - parse to verify structure
|
|
var settings map[string]any
|
|
Expect(json.Unmarshal([]byte(output.StringVal), &settings)).To(Succeed())
|
|
Expect(settings).To(HaveKeyWithValue("enabled", true))
|
|
Expect(settings).To(HaveKeyWithValue("count", float64(5)))
|
|
})
|
|
|
|
It("should list all config keys including complex values", func() {
|
|
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
|
|
Operation: "list",
|
|
Prefix: "",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(output.Keys).To(ConsistOf("api_key", "users", "settings"))
|
|
})
|
|
})
|
|
})
|