mirror of
https://github.com/navidrome/navidrome.git
synced 2026-06-02 07:01:36 +00:00
* feat(conf): add Dir type with lazy directory creation Introduces the Dir type that wraps a directory path string and defers os.MkdirAll until the first call to Path() or MustPath(), using sync.Once to ensure the creation happens exactly once. Implements fmt.Stringer, encoding.TextMarshaler, and encoding.TextUnmarshaler for config integration. Includes Ginkgo/Gomega tests covering all methods and error paths. * refactor(conf): replace eager dir creation with lazy Dir type Change DataFolder, CacheFolder, Plugins.Folder, and Backup.Path from string to Dir. Remove all os.MkdirAll calls from Load() so directories are created lazily on first Path()/MustPath() call. Artwork folder creation was already handled at point-of-use in image_upload.go. Add SnapshotConfig() to conf package for safe test config save/restore that avoids copying sync.Once inside Dir fields. Fix copy-lock vet warning in nativeapi/config.go by marshalling pointer instead of value. * refactor(conf): migrate tests and db init to lazy Dir type Update all test files to use conf.NewDir() for Dir field assignments. Ensure DataFolder is created lazily when the database is first opened in db.Db(). Remove eager directory creation from conf.Load() tests. * fix(conf): address review findings for Dir type - Use os.ModePerm for DataFolder/CacheFolder (was 0700, should match original behavior). Add NewDirWithPerm for PluginsFolder (0700). - Use Path() instead of MustPath() in db.Prune() to avoid logFatal from background cron job. - Panic on marshal/unmarshal errors in SnapshotConfig (test helper). - Clean up redundant String()/MustPath() calls in plugin manager. - Remove dead code in dir_test.go. Signed-off-by: Deluan <deluan@navidrome.org> * fix(conf): add GoString to Dir for clean config dump output Implement fmt.GoStringer on Dir so pretty.Sprintf shows the path string instead of internal struct fields (sync.Once, perm, err). Also add TODO comment to configtest about removing the indirection. * fix(dir): improve error logging in MustPath method Signed-off-by: Deluan <deluan@navidrome.org> * refactor(tests): remove redundant tests for unwritable DataFolder and CacheFolder Signed-off-by: Deluan <deluan@navidrome.org> * fix(conf): address PR review feedback - Ensure Plugins.Folder always uses 0700, even when user-configured (previously only the derived default got restrictive permissions). - Create LogFile parent directory before opening, so LogFile paths inside a not-yet-created DataFolder work correctly. --------- Signed-off-by: Deluan <deluan@navidrome.org>
382 lines
11 KiB
Go
382 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 = 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"))
|
|
})
|
|
})
|
|
})
|