Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
403 lines
14 KiB
Go
403 lines
14 KiB
Go
package plugins
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("DiscoverPlugins", func() {
|
|
var tempPluginsDir string
|
|
|
|
// Helper to create a valid plugin for discovery testing
|
|
createValidPlugin := func(name, manifestName, author, version string, capabilities []string) {
|
|
pluginDir := filepath.Join(tempPluginsDir, name)
|
|
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": "` + version + `",
|
|
"capabilities": [`
|
|
for i, cap := range capabilities {
|
|
if i > 0 {
|
|
manifest += `, `
|
|
}
|
|
manifest += `"` + cap + `"`
|
|
}
|
|
manifest += `],
|
|
"author": "` + author + `",
|
|
"description": "Test Plugin",
|
|
"website": "https://test.navidrome.org/` + manifestName + `",
|
|
"permissions": {}
|
|
}`
|
|
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
|
}
|
|
|
|
createManifestOnlyPlugin := func(name string) {
|
|
pluginDir := filepath.Join(tempPluginsDir, name)
|
|
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
|
|
|
|
manifest := `{
|
|
"name": "manifest-only",
|
|
"version": "1.0.0",
|
|
"capabilities": ["MetadataAgent"],
|
|
"author": "Test Author",
|
|
"description": "Test Plugin",
|
|
"website": "https://test.navidrome.org/manifest-only",
|
|
"permissions": {}
|
|
}`
|
|
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
|
}
|
|
|
|
createWasmOnlyPlugin := func(name string) {
|
|
pluginDir := filepath.Join(tempPluginsDir, name)
|
|
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())
|
|
}
|
|
|
|
createInvalidManifestPlugin := func(name string) {
|
|
pluginDir := filepath.Join(tempPluginsDir, name)
|
|
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())
|
|
|
|
invalidManifest := `{ "invalid": "json" }`
|
|
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(invalidManifest), 0600)).To(Succeed())
|
|
}
|
|
|
|
createEmptyCapabilitiesPlugin := func(name string) {
|
|
pluginDir := filepath.Join(tempPluginsDir, name)
|
|
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": "empty-capabilities",
|
|
"version": "1.0.0",
|
|
"capabilities": [],
|
|
"author": "Test Author",
|
|
"description": "Test Plugin",
|
|
"website": "https://test.navidrome.org/empty-capabilities",
|
|
"permissions": {}
|
|
}`
|
|
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
|
}
|
|
|
|
BeforeEach(func() {
|
|
tempPluginsDir, _ = os.MkdirTemp("", "navidrome-plugins-discovery-test-*")
|
|
DeferCleanup(func() {
|
|
_ = os.RemoveAll(tempPluginsDir)
|
|
})
|
|
})
|
|
|
|
Context("Valid plugins", func() {
|
|
It("should discover valid plugins with all required files", func() {
|
|
createValidPlugin("test-plugin", "Test Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"})
|
|
createValidPlugin("another-plugin", "Another Plugin", "Another Author", "2.0.0", []string{"Scrobbler"})
|
|
|
|
discoveries := DiscoverPlugins(tempPluginsDir)
|
|
|
|
Expect(discoveries).To(HaveLen(2))
|
|
|
|
// Find each plugin by ID
|
|
var testPlugin, anotherPlugin *PluginDiscoveryEntry
|
|
for i := range discoveries {
|
|
switch discoveries[i].ID {
|
|
case "test-plugin":
|
|
testPlugin = &discoveries[i]
|
|
case "another-plugin":
|
|
anotherPlugin = &discoveries[i]
|
|
}
|
|
}
|
|
|
|
Expect(testPlugin).NotTo(BeNil())
|
|
Expect(testPlugin.Error).To(BeNil())
|
|
Expect(testPlugin.Manifest.Name).To(Equal("Test Plugin"))
|
|
Expect(string(testPlugin.Manifest.Capabilities[0])).To(Equal("MetadataAgent"))
|
|
|
|
Expect(anotherPlugin).NotTo(BeNil())
|
|
Expect(anotherPlugin.Error).To(BeNil())
|
|
Expect(anotherPlugin.Manifest.Name).To(Equal("Another Plugin"))
|
|
Expect(string(anotherPlugin.Manifest.Capabilities[0])).To(Equal("Scrobbler"))
|
|
})
|
|
|
|
It("should handle plugins with same manifest name in different directories", func() {
|
|
createValidPlugin("lastfm-official", "lastfm", "Official Author", "1.0.0", []string{"MetadataAgent"})
|
|
createValidPlugin("lastfm-custom", "lastfm", "Custom Author", "2.0.0", []string{"MetadataAgent"})
|
|
|
|
discoveries := DiscoverPlugins(tempPluginsDir)
|
|
|
|
Expect(discoveries).To(HaveLen(2))
|
|
|
|
// Find each plugin by ID
|
|
var officialPlugin, customPlugin *PluginDiscoveryEntry
|
|
for i := range discoveries {
|
|
switch discoveries[i].ID {
|
|
case "lastfm-official":
|
|
officialPlugin = &discoveries[i]
|
|
case "lastfm-custom":
|
|
customPlugin = &discoveries[i]
|
|
}
|
|
}
|
|
|
|
Expect(officialPlugin).NotTo(BeNil())
|
|
Expect(officialPlugin.Error).To(BeNil())
|
|
Expect(officialPlugin.Manifest.Name).To(Equal("lastfm"))
|
|
Expect(officialPlugin.Manifest.Author).To(Equal("Official Author"))
|
|
|
|
Expect(customPlugin).NotTo(BeNil())
|
|
Expect(customPlugin.Error).To(BeNil())
|
|
Expect(customPlugin.Manifest.Name).To(Equal("lastfm"))
|
|
Expect(customPlugin.Manifest.Author).To(Equal("Custom Author"))
|
|
})
|
|
})
|
|
|
|
Context("Missing files", func() {
|
|
It("should report error for plugins missing WASM files", func() {
|
|
createManifestOnlyPlugin("manifest-only")
|
|
|
|
discoveries := DiscoverPlugins(tempPluginsDir)
|
|
|
|
Expect(discoveries).To(HaveLen(1))
|
|
Expect(discoveries[0].ID).To(Equal("manifest-only"))
|
|
Expect(discoveries[0].Error).To(HaveOccurred())
|
|
Expect(discoveries[0].Error.Error()).To(ContainSubstring("no plugin.wasm found"))
|
|
})
|
|
|
|
It("should skip directories missing manifest files", func() {
|
|
createWasmOnlyPlugin("wasm-only")
|
|
|
|
discoveries := DiscoverPlugins(tempPluginsDir)
|
|
|
|
Expect(discoveries).To(HaveLen(1))
|
|
Expect(discoveries[0].ID).To(Equal("wasm-only"))
|
|
Expect(discoveries[0].Error).To(HaveOccurred())
|
|
Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to load manifest"))
|
|
})
|
|
})
|
|
|
|
Context("Invalid content", func() {
|
|
It("should report error for invalid manifest JSON", func() {
|
|
createInvalidManifestPlugin("invalid-manifest")
|
|
|
|
discoveries := DiscoverPlugins(tempPluginsDir)
|
|
|
|
Expect(discoveries).To(HaveLen(1))
|
|
Expect(discoveries[0].ID).To(Equal("invalid-manifest"))
|
|
Expect(discoveries[0].Error).To(HaveOccurred())
|
|
Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to load manifest"))
|
|
})
|
|
|
|
It("should report error for plugins with empty capabilities", func() {
|
|
createEmptyCapabilitiesPlugin("empty-capabilities")
|
|
|
|
discoveries := DiscoverPlugins(tempPluginsDir)
|
|
|
|
Expect(discoveries).To(HaveLen(1))
|
|
Expect(discoveries[0].ID).To(Equal("empty-capabilities"))
|
|
Expect(discoveries[0].Error).To(HaveOccurred())
|
|
Expect(discoveries[0].Error.Error()).To(ContainSubstring("field capabilities length: must be >= 1"))
|
|
})
|
|
})
|
|
|
|
Context("Symlinks", func() {
|
|
It("should discover symlinked plugins correctly", func() {
|
|
// Create a real plugin directory outside tempPluginsDir
|
|
realPluginDir, err := os.MkdirTemp("", "navidrome-real-plugin-*")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
DeferCleanup(func() {
|
|
_ = os.RemoveAll(realPluginDir)
|
|
})
|
|
|
|
// Create plugin files in the real directory
|
|
sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm")
|
|
targetWasmPath := filepath.Join(realPluginDir, "plugin.wasm")
|
|
sourceWasm, err := os.ReadFile(sourceWasmPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed())
|
|
|
|
manifest := `{
|
|
"name": "symlinked-plugin",
|
|
"version": "1.0.0",
|
|
"capabilities": ["MetadataAgent"],
|
|
"author": "Test Author",
|
|
"description": "Test Plugin",
|
|
"website": "https://test.navidrome.org/symlinked-plugin",
|
|
"permissions": {}
|
|
}`
|
|
Expect(os.WriteFile(filepath.Join(realPluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
|
|
|
// Create symlink
|
|
symlinkPath := filepath.Join(tempPluginsDir, "symlinked-plugin")
|
|
Expect(os.Symlink(realPluginDir, symlinkPath)).To(Succeed())
|
|
|
|
discoveries := DiscoverPlugins(tempPluginsDir)
|
|
|
|
Expect(discoveries).To(HaveLen(1))
|
|
Expect(discoveries[0].ID).To(Equal("symlinked-plugin"))
|
|
Expect(discoveries[0].Error).To(BeNil())
|
|
Expect(discoveries[0].IsSymlink).To(BeTrue())
|
|
Expect(discoveries[0].Path).To(Equal(realPluginDir))
|
|
Expect(discoveries[0].Manifest.Name).To(Equal("symlinked-plugin"))
|
|
})
|
|
|
|
It("should handle relative symlinks", func() {
|
|
// Create a real plugin directory in the same parent as tempPluginsDir
|
|
parentDir := filepath.Dir(tempPluginsDir)
|
|
realPluginDir := filepath.Join(parentDir, "real-plugin-dir")
|
|
Expect(os.MkdirAll(realPluginDir, 0755)).To(Succeed())
|
|
DeferCleanup(func() {
|
|
_ = os.RemoveAll(realPluginDir)
|
|
})
|
|
|
|
// Create plugin files in the real directory
|
|
sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm")
|
|
targetWasmPath := filepath.Join(realPluginDir, "plugin.wasm")
|
|
sourceWasm, err := os.ReadFile(sourceWasmPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed())
|
|
|
|
manifest := `{
|
|
"name": "relative-symlinked-plugin",
|
|
"version": "1.0.0",
|
|
"capabilities": ["MetadataAgent"],
|
|
"author": "Test Author",
|
|
"description": "Test Plugin",
|
|
"website": "https://test.navidrome.org/relative-symlinked-plugin",
|
|
"permissions": {}
|
|
}`
|
|
Expect(os.WriteFile(filepath.Join(realPluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
|
|
|
// Create relative symlink
|
|
symlinkPath := filepath.Join(tempPluginsDir, "relative-symlinked-plugin")
|
|
relativeTarget := "../real-plugin-dir"
|
|
Expect(os.Symlink(relativeTarget, symlinkPath)).To(Succeed())
|
|
|
|
discoveries := DiscoverPlugins(tempPluginsDir)
|
|
|
|
Expect(discoveries).To(HaveLen(1))
|
|
Expect(discoveries[0].ID).To(Equal("relative-symlinked-plugin"))
|
|
Expect(discoveries[0].Error).To(BeNil())
|
|
Expect(discoveries[0].IsSymlink).To(BeTrue())
|
|
Expect(discoveries[0].Path).To(Equal(realPluginDir))
|
|
Expect(discoveries[0].Manifest.Name).To(Equal("relative-symlinked-plugin"))
|
|
})
|
|
|
|
It("should report error for broken symlinks", func() {
|
|
symlinkPath := filepath.Join(tempPluginsDir, "broken-symlink")
|
|
nonExistentTarget := "/non/existent/path"
|
|
Expect(os.Symlink(nonExistentTarget, symlinkPath)).To(Succeed())
|
|
|
|
discoveries := DiscoverPlugins(tempPluginsDir)
|
|
|
|
Expect(discoveries).To(HaveLen(1))
|
|
Expect(discoveries[0].ID).To(Equal("broken-symlink"))
|
|
Expect(discoveries[0].Error).To(HaveOccurred())
|
|
Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to stat symlink target"))
|
|
Expect(discoveries[0].IsSymlink).To(BeTrue())
|
|
})
|
|
|
|
It("should report error for symlinks pointing to files", func() {
|
|
// Create a regular file
|
|
regularFile := filepath.Join(tempPluginsDir, "regular-file.txt")
|
|
Expect(os.WriteFile(regularFile, []byte("content"), 0600)).To(Succeed())
|
|
|
|
// Create symlink pointing to the file
|
|
symlinkPath := filepath.Join(tempPluginsDir, "symlink-to-file")
|
|
Expect(os.Symlink(regularFile, symlinkPath)).To(Succeed())
|
|
|
|
discoveries := DiscoverPlugins(tempPluginsDir)
|
|
|
|
Expect(discoveries).To(HaveLen(1))
|
|
Expect(discoveries[0].ID).To(Equal("symlink-to-file"))
|
|
Expect(discoveries[0].Error).To(HaveOccurred())
|
|
Expect(discoveries[0].Error.Error()).To(ContainSubstring("symlink target is not a directory"))
|
|
Expect(discoveries[0].IsSymlink).To(BeTrue())
|
|
})
|
|
})
|
|
|
|
Context("Directory filtering", func() {
|
|
It("should ignore hidden directories", func() {
|
|
createValidPlugin(".hidden-plugin", "Hidden Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"})
|
|
createValidPlugin("visible-plugin", "Visible Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"})
|
|
|
|
discoveries := DiscoverPlugins(tempPluginsDir)
|
|
|
|
Expect(discoveries).To(HaveLen(1))
|
|
Expect(discoveries[0].ID).To(Equal("visible-plugin"))
|
|
})
|
|
|
|
It("should ignore regular files", func() {
|
|
// Create a regular file
|
|
Expect(os.WriteFile(filepath.Join(tempPluginsDir, "regular-file.txt"), []byte("content"), 0600)).To(Succeed())
|
|
createValidPlugin("valid-plugin", "Valid Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"})
|
|
|
|
discoveries := DiscoverPlugins(tempPluginsDir)
|
|
|
|
Expect(discoveries).To(HaveLen(1))
|
|
Expect(discoveries[0].ID).To(Equal("valid-plugin"))
|
|
})
|
|
|
|
It("should handle mixed valid and invalid plugins", func() {
|
|
createValidPlugin("valid-plugin", "Valid Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"})
|
|
createManifestOnlyPlugin("manifest-only")
|
|
createInvalidManifestPlugin("invalid-manifest")
|
|
createValidPlugin("another-valid", "Another Valid", "Test Author", "1.0.0", []string{"Scrobbler"})
|
|
|
|
discoveries := DiscoverPlugins(tempPluginsDir)
|
|
|
|
Expect(discoveries).To(HaveLen(4))
|
|
|
|
var validCount int
|
|
var errorCount int
|
|
for _, discovery := range discoveries {
|
|
if discovery.Error == nil {
|
|
validCount++
|
|
} else {
|
|
errorCount++
|
|
}
|
|
}
|
|
|
|
Expect(validCount).To(Equal(2))
|
|
Expect(errorCount).To(Equal(2))
|
|
})
|
|
})
|
|
|
|
Context("Error handling", func() {
|
|
It("should handle non-existent plugins directory", func() {
|
|
nonExistentDir := "/non/existent/plugins/dir"
|
|
|
|
discoveries := DiscoverPlugins(nonExistentDir)
|
|
|
|
Expect(discoveries).To(HaveLen(1))
|
|
Expect(discoveries[0].Error).To(HaveOccurred())
|
|
Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to read plugins directory"))
|
|
})
|
|
})
|
|
})
|