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