navidrome/plugins/plugins_suite_test.go
Deluan Quintão 8f0b4930ff
refactor(conf): replace eager dir creation with lazy Dir type (#5495)
* 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>
2026-05-13 17:44:22 -03:00

179 lines
5.8 KiB
Go

//go:build !windows
package plugins
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const testDataDir = "plugins/testdata"
// Shared test state initialized in BeforeSuite
var (
testdataDir string // Path to testdata folder with test plugin .ndp packages
tmpPluginsDir string // Temp directory for plugin tests that modify files
testManager *Manager
)
func TestPlugins(t *testing.T) {
tests.Init(t, false)
buildTestPlugins(t, testDataDir)
// Create a shared wazero compilation cache directory.
// All test managers will point CacheFolder here so that WASM compilation
// is done once per binary and then reused from disk cache.
sharedCacheDir, err := os.MkdirTemp("", "plugins-shared-cache-*")
if err != nil {
t.Fatalf("Failed to create shared cache dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(sharedCacheDir) })
// Set CacheFolder globally so all tests (including those using
// configtest.SetupConfig) inherit it without needing to set it manually.
conf.Server.CacheFolder = conf.NewDir(sharedCacheDir)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Plugins Suite")
}
func buildTestPlugins(t *testing.T, path string) {
t.Helper()
start := time.Now()
t.Logf("[BeforeSuite] Current working directory: %s", path)
cmd := exec.Command("make", "-C", path)
out, err := cmd.CombinedOutput()
t.Logf("[BeforeSuite] Make output: %s elapsed: %s", string(out), time.Since(start))
if err != nil {
t.Fatalf("Failed to build test plugins: %v", err)
}
}
// createTestManager creates a new plugin Manager with the given plugin config.
// It creates a temp directory, copies the test-metadata-agent plugin, and starts the manager.
// Returns the manager, temp directory path, and a cleanup function.
func createTestManager(pluginConfig map[string]map[string]string) (*Manager, string) {
return createTestManagerWithPlugins(pluginConfig, "test-metadata-agent"+PackageExtension)
}
// createTestManagerWithPlugins creates a new plugin Manager with the given plugin config
// and specified plugins. It creates a temp directory, copies the specified plugins, and starts the manager.
// Returns the manager and temp directory path.
func createTestManagerWithPlugins(pluginConfig map[string]map[string]string, plugins ...string) (*Manager, string) {
return createTestManagerWithPluginsAndMetrics(pluginConfig, noopMetricsRecorder{}, plugins...)
}
// createTestManagerWithPluginsAndMetrics creates a new plugin Manager with the given plugin config,
// metrics recorder, and specified plugins. It creates a temp directory, copies the specified plugins, and starts the manager.
// Returns the manager and temp directory path.
func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]string, metrics PluginMetricsRecorder, plugins ...string) (*Manager, string) {
// Create temp directory
tmpDir, err := os.MkdirTemp("", "plugins-test-*")
Expect(err).ToNot(HaveOccurred())
// Copy test plugins to temp dir and build plugin list with SHA256
var enabledPlugins model.Plugins
for _, plugin := range plugins {
srcPath := filepath.Join(testdataDir, plugin)
destPath := filepath.Join(tmpDir, plugin)
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[:])
pluginName := plugin[:len(plugin)-len(PackageExtension)] // Remove .ndp extension
// Build config JSON if provided
configJSON := ""
if pluginConfig != nil && pluginConfig[pluginName] != nil {
// Encode config to JSON
configBytes, err := json.Marshal(pluginConfig[pluginName])
Expect(err).ToNot(HaveOccurred())
configJSON = string(configBytes)
}
enabledPlugins = append(enabledPlugins, model.Plugin{
ID: pluginName,
Path: destPath,
SHA256: hashHex,
Enabled: true,
Config: configJSON,
AllUsers: true, // Allow all users by default in tests
})
}
// 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 with pre-enabled plugins
mockPluginRepo := tests.CreateMockPluginRepo()
mockPluginRepo.Permitted = true
mockPluginRepo.SetData(enabledPlugins)
dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo}
// Create and start manager
manager := &Manager{
plugins: make(map[string]*plugin),
ds: dataStore,
metrics: metrics,
subsonicRouter: http.NotFoundHandler(), // Stub router for tests
}
err = manager.Start(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = manager.Stop()
_ = os.RemoveAll(tmpDir)
})
return manager, tmpDir
}
var _ = BeforeSuite(func() {
// Get testdata directory (where test plugin .ndp packages live)
_, currentFile, _, ok := runtime.Caller(0)
Expect(ok).To(BeTrue())
testdataDir = filepath.Join(filepath.Dir(currentFile), "testdata")
// Create shared manager for most tests
testManager, tmpPluginsDir = createTestManager(nil)
})
var _ = AfterSuite(func() {
if testManager != nil {
_ = testManager.Stop()
}
if tmpPluginsDir != "" {
_ = os.RemoveAll(tmpPluginsDir)
}
})
// noopMetricsRecorder is a no-op implementation of PluginMetricsRecorder for tests
type noopMetricsRecorder struct{}
func (noopMetricsRecorder) RecordPluginRequest(context.Context, string, string, bool, int64) {}