🏡 index : quine-global/quintodrome.git

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 = conf.NewDir(tmpDir)
	conf.Server.Plugins.AutoReload = false

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