navidrome/plugins/manager_test.go
Kendall Garner 0cd15c1ddc
feat(prometheus): add metrics to Subsonic API and Plugins (#4266)
* Add prometheus metrics to subsonic and plugins

* address feedback, do not log error if operation is not supported

* add missing timestamp and client to stats

* remove .view from subsonic route

* directly inject DataStore in Prometheus, to avoid having to pass it in every call

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-06-27 22:13:57 -04:00

258 lines
9.0 KiB
Go

package plugins
import (
"context"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Plugin Manager", func() {
var mgr *Manager
var ctx context.Context
BeforeEach(func() {
// We change the plugins folder to random location to avoid conflicts with other tests,
// but, as this is an integration test, we can't use configtest.SetupConfig() as it causes
// data races.
originalPluginsFolder := conf.Server.Plugins.Folder
DeferCleanup(func() {
conf.Server.Plugins.Folder = originalPluginsFolder
})
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = testDataDir
ctx = GinkgoT().Context()
mgr = createManager(nil, nil)
mgr.ScanPlugins()
})
It("should scan and discover plugins from the testdata folder", func() {
Expect(mgr).NotTo(BeNil())
mediaAgentNames := mgr.PluginNames("MetadataAgent")
Expect(mediaAgentNames).To(HaveLen(4))
Expect(mediaAgentNames).To(ContainElement("fake_artist_agent"))
Expect(mediaAgentNames).To(ContainElement("fake_album_agent"))
Expect(mediaAgentNames).To(ContainElement("multi_plugin"))
Expect(mediaAgentNames).To(ContainElement("unauthorized_plugin"))
scrobblerNames := mgr.PluginNames("Scrobbler")
Expect(scrobblerNames).To(ContainElement("fake_scrobbler"))
initServiceNames := mgr.PluginNames("LifecycleManagement")
Expect(initServiceNames).To(ContainElement("multi_plugin"))
Expect(initServiceNames).To(ContainElement("fake_init_service"))
})
It("should load a MetadataAgent plugin and invoke artist-related methods", func() {
plugin := mgr.LoadPlugin("fake_artist_agent", CapabilityMetadataAgent)
Expect(plugin).NotTo(BeNil())
agent, ok := plugin.(agents.Interface)
Expect(ok).To(BeTrue(), "plugin should implement agents.Interface")
Expect(agent.AgentName()).To(Equal("fake_artist_agent"))
mbidRetriever, ok := agent.(agents.ArtistMBIDRetriever)
Expect(ok).To(BeTrue())
mbid, err := mbidRetriever.GetArtistMBID(ctx, "123", "The Beatles")
Expect(err).NotTo(HaveOccurred())
Expect(mbid).To(Equal("1234567890"))
})
It("should load all MetadataAgent plugins", func() {
agents := mgr.LoadAllMediaAgents()
Expect(agents).To(HaveLen(4))
var names []string
for _, a := range agents {
names = append(names, a.AgentName())
}
Expect(names).To(ContainElements("fake_artist_agent", "fake_album_agent", "multi_plugin", "unauthorized_plugin"))
})
Describe("ScanPlugins", func() {
var tempPluginsDir string
var m *Manager
BeforeEach(func() {
tempPluginsDir, _ = os.MkdirTemp("", "navidrome-plugins-test-*")
DeferCleanup(func() {
_ = os.RemoveAll(tempPluginsDir)
})
conf.Server.Plugins.Folder = tempPluginsDir
m = createManager(nil, nil)
})
// Helper to create a complete valid plugin for manager testing
createValidPlugin := func(folderName, manifestName string) {
pluginDir := filepath.Join(tempPluginsDir, folderName)
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
// Copy real WASM file from testdata
sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm")
targetWasmPath := filepath.Join(pluginDir, "plugin.wasm")
sourceWasm, err := os.ReadFile(sourceWasmPath)
Expect(err).ToNot(HaveOccurred())
Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed())
manifest := `{
"name": "` + manifestName + `",
"version": "1.0.0",
"capabilities": ["MetadataAgent"],
"author": "Test Author",
"description": "Test Plugin",
"website": "https://test.navidrome.org/` + manifestName + `",
"permissions": {}
}`
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
}
It("should register and compile discovered plugins", func() {
createValidPlugin("test-plugin", "test-plugin")
m.ScanPlugins()
// Focus on manager behavior: registration and compilation
Expect(m.plugins).To(HaveLen(1))
Expect(m.plugins).To(HaveKey("test-plugin"))
plugin := m.plugins["test-plugin"]
Expect(plugin.ID).To(Equal("test-plugin"))
Expect(plugin.Manifest.Name).To(Equal("test-plugin"))
// Verify plugin can be loaded (compilation successful)
loadedPlugin := m.LoadPlugin("test-plugin", CapabilityMetadataAgent)
Expect(loadedPlugin).NotTo(BeNil())
})
It("should handle multiple plugins with different IDs but same manifest names", func() {
// This tests manager-specific behavior: how it handles ID conflicts
createValidPlugin("lastfm-official", "lastfm")
createValidPlugin("lastfm-custom", "lastfm")
m.ScanPlugins()
// Both should be registered with their folder names as IDs
Expect(m.plugins).To(HaveLen(2))
Expect(m.plugins).To(HaveKey("lastfm-official"))
Expect(m.plugins).To(HaveKey("lastfm-custom"))
// Both should be loadable independently
official := m.LoadPlugin("lastfm-official", CapabilityMetadataAgent)
custom := m.LoadPlugin("lastfm-custom", CapabilityMetadataAgent)
Expect(official).NotTo(BeNil())
Expect(custom).NotTo(BeNil())
Expect(official.PluginID()).To(Equal("lastfm-official"))
Expect(custom.PluginID()).To(Equal("lastfm-custom"))
})
})
Describe("LoadPlugin", func() {
It("should load a MetadataAgent plugin and invoke artist-related methods", func() {
plugin := mgr.LoadPlugin("fake_artist_agent", CapabilityMetadataAgent)
Expect(plugin).NotTo(BeNil())
agent, ok := plugin.(agents.Interface)
Expect(ok).To(BeTrue(), "plugin should implement agents.Interface")
Expect(agent.AgentName()).To(Equal("fake_artist_agent"))
mbidRetriever, ok := agent.(agents.ArtistMBIDRetriever)
Expect(ok).To(BeTrue())
mbid, err := mbidRetriever.GetArtistMBID(ctx, "id", "Test Artist")
Expect(err).NotTo(HaveOccurred())
Expect(mbid).To(Equal("1234567890"))
})
})
Describe("EnsureCompiled", func() {
It("should successfully wait for plugin compilation", func() {
err := mgr.EnsureCompiled("fake_artist_agent")
Expect(err).NotTo(HaveOccurred())
})
It("should return error for non-existent plugin", func() {
err := mgr.EnsureCompiled("non-existent-plugin")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("plugin not found: non-existent-plugin"))
})
It("should wait for compilation to complete for all valid plugins", func() {
pluginNames := []string{"fake_artist_agent", "fake_album_agent", "multi_plugin", "fake_scrobbler"}
for _, name := range pluginNames {
err := mgr.EnsureCompiled(name)
Expect(err).NotTo(HaveOccurred(), "plugin %s should compile successfully", name)
}
})
})
Describe("Invoke Methods", func() {
It("should load all MetadataAgent plugins and invoke methods", func() {
mediaAgentNames := mgr.PluginNames("MetadataAgent")
Expect(mediaAgentNames).NotTo(BeEmpty())
plugins := mgr.LoadAllPlugins("MetadataAgent")
Expect(plugins).To(HaveLen(len(mediaAgentNames)))
var fakeAlbumPlugin agents.Interface
for _, p := range plugins {
if agent, ok := p.(agents.Interface); ok {
if agent.AgentName() == "fake_album_agent" {
fakeAlbumPlugin = agent
break
}
}
}
Expect(fakeAlbumPlugin).NotTo(BeNil(), "fake_album_agent should be loaded")
// Test GetAlbumInfo method - need to cast to the specific interface
albumRetriever, ok := fakeAlbumPlugin.(agents.AlbumInfoRetriever)
Expect(ok).To(BeTrue(), "fake_album_agent should implement AlbumInfoRetriever")
info, err := albumRetriever.GetAlbumInfo(ctx, "Test Album", "Test Artist", "123")
Expect(err).NotTo(HaveOccurred())
Expect(info).NotTo(BeNil())
Expect(info.Name).To(Equal("Test Album"))
})
})
Describe("Permission Enforcement Integration", func() {
It("should fail when plugin tries to access unauthorized services", func() {
// This plugin tries to access config service but has no permissions
plugin := mgr.LoadPlugin("unauthorized_plugin", CapabilityMetadataAgent)
Expect(plugin).NotTo(BeNil())
agent, ok := plugin.(agents.Interface)
Expect(ok).To(BeTrue())
// This should fail because the plugin tries to access unauthorized config service
// The exact behavior depends on the plugin implementation, but it should either:
// 1. Fail during instantiation, or
// 2. Return an error when trying to call config methods
// Try to use one of the available methods - let's test with GetArtistMBID
mbidRetriever, isMBIDRetriever := agent.(agents.ArtistMBIDRetriever)
if isMBIDRetriever {
_, err := mbidRetriever.GetArtistMBID(ctx, "id", "Test Artist")
if err == nil {
// If no error, the plugin should still be working
// but any config access should fail silently or return default values
Expect(agent.AgentName()).To(Equal("unauthorized_plugin"))
} else {
// If there's an error, it should be related to missing permissions
Expect(err.Error()).To(ContainSubstring(""))
}
} else {
// If the plugin doesn't implement the interface, that's also acceptable
Expect(agent.AgentName()).To(Equal("unauthorized_plugin"))
}
})
})
})