navidrome/plugins/plugins_suite_test.go
Deluan Quintão 101145742f
test(plugins): stub DNS in the host SSRF guard tests (#6208)
The dial-time SSRF guard runs on the resolved IP, so the tests that prove a
symbolic hostname cannot reach loopback used "localhost." — a trailing dot never
matches /etc/hosts, so Go queries real DNS. Machines whose resolver does not
answer "localhost." (a VPN DNS, for example) got "no such host" before the dial
guard ever ran, failing three specs.

Add tests.StubResolver, a net.Resolver backed by an in-memory DNS responder over
net.Pipe, and let the plugin dialers take a resolver so tests can inject it.
Name resolution in those specs no longer depends on the machine's DNS.
2026-09-23 18:42:11 -04:00

163 lines
5.5 KiB
Go

package plugins
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"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"
wazeroCacheDir = ".wazero-cache"
)
// 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)
// Set globally so tests using configtest.SetupConfig inherit it. The cache
// persists between runs; entries are content-addressed, so a stale one only misses.
conf.Server.CacheFolder = conf.NewDir(filepath.Join(testDataDir, wazeroCacheDir))
conf.Server.Plugins.CacheSize = "1GB" // the default evicts the cache mid-run
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Plugins Suite")
}
// stubLocalhostDNS resolves "localhost." without real DNS: the trailing dot never matches /etc/hosts.
func stubLocalhostDNS() {
dialResolver = tests.StubResolver(map[string]string{"localhost.": "127.0.0.1"})
DeferCleanup(func() { dialResolver = nil })
}
// 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...)
}
// installTestPlugins copies the given .ndp packages into dir and returns their
// enabled DB rows, so callers can grant whatever access the test needs.
func installTestPlugins(dir string, plugins ...string) model.Plugins {
var rows model.Plugins
for _, plugin := range plugins {
data, err := os.ReadFile(filepath.Join(testdataDir, plugin))
Expect(err).ToNot(HaveOccurred())
destPath := filepath.Join(dir, plugin)
Expect(os.WriteFile(destPath, data, 0600)).To(Succeed())
hash := sha256.Sum256(data)
rows = append(rows, model.Plugin{
ID: strings.TrimSuffix(plugin, PackageExtension),
Path: destPath,
SHA256: hex.EncodeToString(hash[:]),
Enabled: true,
})
}
return rows
}
// 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())
enabledPlugins := installTestPlugins(tmpDir, plugins...)
for i, p := range enabledPlugins {
enabledPlugins[i].AllUsers = true // Allow all users by default in tests
if pluginConfig[p.ID] != nil {
configBytes, err := json.Marshal(pluginConfig[p.ID])
Expect(err).ToNot(HaveOccurred())
enabledPlugins[i].Config = string(configBytes)
}
}
// 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 _ = SynchronizedBeforeSuite(func() {
// Build once: the testdata Makefile is not safe to run concurrently.
buildTestPlugins(testDataDir)
}, 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) {}