mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 15:08:04 -05:00
368 lines
13 KiB
Go
368 lines
13 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/core/agents"
|
|
"github.com/navidrome/navidrome/core/metrics"
|
|
"github.com/navidrome/navidrome/plugins/schema"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Plugin Manager", func() {
|
|
var mgr *managerImpl
|
|
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
|
|
originalTimeout := conf.Server.DevPluginCompilationTimeout
|
|
conf.Server.DevPluginCompilationTimeout = 2 * time.Minute
|
|
DeferCleanup(func() {
|
|
conf.Server.Plugins.Folder = originalPluginsFolder
|
|
conf.Server.DevPluginCompilationTimeout = originalTimeout
|
|
})
|
|
conf.Server.Plugins.Enabled = true
|
|
conf.Server.Plugins.Folder = testDataDir
|
|
|
|
ctx = GinkgoT().Context()
|
|
mgr = createManager(nil, metrics.NewNoopInstance())
|
|
mgr.ScanPlugins()
|
|
|
|
// Wait for all plugins to compile to avoid race conditions
|
|
err := mgr.EnsureCompiled("fake_artist_agent")
|
|
Expect(err).NotTo(HaveOccurred(), "fake_artist_agent should compile successfully")
|
|
err = mgr.EnsureCompiled("fake_album_agent")
|
|
Expect(err).NotTo(HaveOccurred(), "fake_album_agent should compile successfully")
|
|
err = mgr.EnsureCompiled("multi_plugin")
|
|
Expect(err).NotTo(HaveOccurred(), "multi_plugin should compile successfully")
|
|
err = mgr.EnsureCompiled("unauthorized_plugin")
|
|
Expect(err).NotTo(HaveOccurred(), "unauthorized_plugin should compile successfully")
|
|
})
|
|
|
|
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(ContainElements(
|
|
"fake_artist_agent",
|
|
"fake_album_agent",
|
|
"multi_plugin",
|
|
"unauthorized_plugin",
|
|
))
|
|
|
|
scrobblerNames := mgr.PluginNames("Scrobbler")
|
|
Expect(scrobblerNames).To(ContainElement("fake_scrobbler"))
|
|
|
|
initServiceNames := mgr.PluginNames("LifecycleManagement")
|
|
Expect(initServiceNames).To(ContainElements("multi_plugin", "fake_init_service"))
|
|
|
|
schedulerCallbackNames := mgr.PluginNames("SchedulerCallback")
|
|
Expect(schedulerCallbackNames).To(ContainElement("multi_plugin"))
|
|
})
|
|
|
|
It("should load all plugins from folder", func() {
|
|
all := mgr.PluginList()
|
|
Expect(all).To(HaveLen(6))
|
|
Expect(all["fake_artist_agent"].Name).To(Equal("fake_artist_agent"))
|
|
Expect(all["unauthorized_plugin"].Capabilities).To(HaveExactElements(schema.PluginManifestCapabilitiesElem("MetadataAgent")))
|
|
})
|
|
|
|
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() {
|
|
mediaAgentNames := mgr.PluginNames("MetadataAgent")
|
|
Expect(mediaAgentNames).To(HaveLen(4))
|
|
|
|
var agentNames []string
|
|
for _, name := range mediaAgentNames {
|
|
agent, ok := mgr.LoadMediaAgent(name)
|
|
if ok {
|
|
agentNames = append(agentNames, agent.AgentName())
|
|
}
|
|
}
|
|
|
|
Expect(agentNames).To(ContainElements("fake_artist_agent", "fake_album_agent", "multi_plugin", "unauthorized_plugin"))
|
|
})
|
|
|
|
Describe("ScanPlugins", func() {
|
|
var tempPluginsDir string
|
|
var m *managerImpl
|
|
|
|
BeforeEach(func() {
|
|
tempPluginsDir, _ = os.MkdirTemp("", "navidrome-plugins-test-*")
|
|
DeferCleanup(func() {
|
|
_ = os.RemoveAll(tempPluginsDir)
|
|
})
|
|
|
|
conf.Server.Plugins.Folder = tempPluginsDir
|
|
m = createManager(nil, metrics.NewNoopInstance())
|
|
})
|
|
|
|
// 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() {
|
|
fakeAlbumPlugin, isMediaAgent := mgr.LoadMediaAgent("fake_album_agent")
|
|
Expect(isMediaAgent).To(BeTrue())
|
|
|
|
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"))
|
|
}
|
|
})
|
|
})
|
|
|
|
Describe("Plugin Initialization Lifecycle", func() {
|
|
BeforeEach(func() {
|
|
conf.Server.Plugins.Enabled = true
|
|
conf.Server.Plugins.Folder = testDataDir
|
|
})
|
|
|
|
Context("when OnInit is successful", func() {
|
|
It("should register and initialize the plugin", func() {
|
|
conf.Server.PluginConfig = nil
|
|
mgr = createManager(nil, metrics.NewNoopInstance()) // Create manager after setting config
|
|
mgr.ScanPlugins()
|
|
|
|
plugin := mgr.plugins["fake_init_service"]
|
|
Expect(plugin).NotTo(BeNil())
|
|
|
|
Eventually(func() bool {
|
|
return mgr.lifecycle.isInitialized(plugin)
|
|
}).Should(BeTrue())
|
|
|
|
// Check that the plugin is still registered
|
|
names := mgr.PluginNames(CapabilityLifecycleManagement)
|
|
Expect(names).To(ContainElement("fake_init_service"))
|
|
})
|
|
})
|
|
|
|
Context("when OnInit fails", func() {
|
|
It("should unregister the plugin if OnInit returns an error string", func() {
|
|
conf.Server.PluginConfig = map[string]map[string]string{
|
|
"fake_init_service": {
|
|
"returnError": "response_error",
|
|
},
|
|
}
|
|
mgr = createManager(nil, metrics.NewNoopInstance()) // Create manager after setting config
|
|
mgr.ScanPlugins()
|
|
|
|
Eventually(func() []string {
|
|
return mgr.PluginNames(CapabilityLifecycleManagement)
|
|
}).ShouldNot(ContainElement("fake_init_service"))
|
|
})
|
|
|
|
It("should unregister the plugin if OnInit returns a Go error", func() {
|
|
conf.Server.PluginConfig = map[string]map[string]string{
|
|
"fake_init_service": {
|
|
"returnError": "go_error",
|
|
},
|
|
}
|
|
mgr = createManager(nil, metrics.NewNoopInstance()) // Create manager after setting config
|
|
mgr.ScanPlugins()
|
|
|
|
Eventually(func() []string {
|
|
return mgr.PluginNames(CapabilityLifecycleManagement)
|
|
}).ShouldNot(ContainElement("fake_init_service"))
|
|
})
|
|
})
|
|
|
|
It("should clear lifecycle state when unregistering a plugin", func() {
|
|
// Create a manager and register a plugin
|
|
mgr := createManager(nil, metrics.NewNoopInstance())
|
|
|
|
// Create a mock plugin with LifecycleManagement capability
|
|
plugin := &plugin{
|
|
ID: "test-plugin",
|
|
Capabilities: []string{CapabilityLifecycleManagement},
|
|
Manifest: &schema.PluginManifest{
|
|
Version: "1.0.0",
|
|
},
|
|
}
|
|
|
|
// Register the plugin in the manager
|
|
mgr.pluginsMu.Lock()
|
|
mgr.plugins[plugin.ID] = plugin
|
|
mgr.pluginsMu.Unlock()
|
|
|
|
// Mark the plugin as initialized in the lifecycle manager
|
|
mgr.lifecycle.markInitialized(plugin)
|
|
Expect(mgr.lifecycle.isInitialized(plugin)).To(BeTrue())
|
|
|
|
// Unregister the plugin
|
|
mgr.unregisterPlugin(plugin.ID)
|
|
|
|
// Verify that the plugin is no longer in the manager
|
|
mgr.pluginsMu.RLock()
|
|
_, exists := mgr.plugins[plugin.ID]
|
|
mgr.pluginsMu.RUnlock()
|
|
Expect(exists).To(BeFalse())
|
|
|
|
// Verify that the lifecycle state has been cleared
|
|
Expect(mgr.lifecycle.isInitialized(plugin)).To(BeFalse())
|
|
})
|
|
})
|
|
})
|