mirror of
https://github.com/navidrome/navidrome.git
synced 2025-07-14 07:31:28 +03:00
* 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>
258 lines
9.0 KiB
Go
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"))
|
|
}
|
|
})
|
|
})
|
|
})
|