Files
navidrome/plugins/runtime_test.go
Deluan Quintão 45c408a674 feat(plugins): allow Plugins to call the Subsonic API (#4260)
* chore: .gitignore any navidrome binary

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

* feat: implement internal authentication handling in middleware

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

* feat(manager): add SubsonicRouter to Manager for API routing

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

* feat(plugins): add SubsonicAPI Host service for plugins and an example plugin

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

* fix lint

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

* feat(plugins): refactor path handling in SubsonicAPI to extract endpoint correctly

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

* docs(plugins): add SubsonicAPI service documentation to README

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

* feat(plugins): implement permission checks for SubsonicAPI service

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

* feat(plugins): enhance SubsonicAPI service initialization with atomic router handling

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

* refactor(plugins): better encapsulated dependency injection

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

* refactor(plugins): rename parameter in WithInternalAuth for clarity

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

* docs(plugins): update SubsonicAPI permissions section in README for clarity and detail

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

* feat(plugins): enhance SubsonicAPI permissions output with allowed usernames and admin flag

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

* feat(plugins): add schema reference to example plugins

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

* remove import alias

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-06-25 14:18:32 -04:00

172 lines
4.7 KiB
Go

package plugins
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/plugins/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/tetratelabs/wazero"
)
var _ = Describe("Runtime", func() {
Describe("pluginCompilationTimeout", func() {
It("should use DevPluginCompilationTimeout config for plugin compilation timeout", func() {
originalTimeout := conf.Server.DevPluginCompilationTimeout
DeferCleanup(func() {
conf.Server.DevPluginCompilationTimeout = originalTimeout
})
conf.Server.DevPluginCompilationTimeout = 123 * time.Second
Expect(pluginCompilationTimeout()).To(Equal(123 * time.Second))
conf.Server.DevPluginCompilationTimeout = 0
Expect(pluginCompilationTimeout()).To(Equal(time.Minute))
})
})
})
var _ = Describe("CachingRuntime", func() {
var (
ctx context.Context
mgr *Manager
plugin *wasmScrobblerPlugin
)
BeforeEach(func() {
ctx = GinkgoT().Context()
mgr = createManager(nil)
// Add permissions for the test plugin using typed struct
permissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "For testing HTTP functionality",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
AllowLocalNetwork: false,
},
Config: &schema.PluginManifestPermissionsConfig{
Reason: "For testing config functionality",
},
}
rtFunc := mgr.createRuntime("fake_scrobbler", permissions)
plugin = newWasmScrobblerPlugin(
filepath.Join(testDataDir, "fake_scrobbler", "plugin.wasm"),
"fake_scrobbler",
rtFunc,
wazero.NewModuleConfig().WithStartFunctions("_initialize"),
).(*wasmScrobblerPlugin)
// runtime will be created on first plugin load
})
It("reuses module instances across calls", func() {
// First call to create the runtime and pool
_, done, err := plugin.getInstance(ctx, "first")
Expect(err).ToNot(HaveOccurred())
done()
val, ok := runtimePool.Load("fake_scrobbler")
Expect(ok).To(BeTrue())
cachingRT := val.(*cachingRuntime)
// Verify the pool exists and is initialized
Expect(cachingRT.pool).ToNot(BeNil())
// Test that multiple calls work without error (indicating pool reuse)
for i := 0; i < 5; i++ {
inst, done, err := plugin.getInstance(ctx, fmt.Sprintf("call_%d", i))
Expect(err).ToNot(HaveOccurred())
Expect(inst).ToNot(BeNil())
done()
}
// Test concurrent access to verify pool handles concurrency
const numGoroutines = 3
errChan := make(chan error, numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(id int) {
inst, done, err := plugin.getInstance(ctx, fmt.Sprintf("concurrent_%d", id))
if err != nil {
errChan <- err
return
}
defer done()
// Verify we got a valid instance
if inst == nil {
errChan <- fmt.Errorf("got nil instance")
return
}
errChan <- nil
}(i)
}
// Check all goroutines succeeded
for i := 0; i < numGoroutines; i++ {
err := <-errChan
Expect(err).To(BeNil())
}
})
})
var _ = Describe("purgeCacheBySize", func() {
var tmpDir string
BeforeEach(func() {
var err error
tmpDir, err = os.MkdirTemp("", "cache_test")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(os.RemoveAll, tmpDir)
})
It("removes oldest entries when above the size limit", func() {
oldDir := filepath.Join(tmpDir, "d1")
newDir := filepath.Join(tmpDir, "d2")
Expect(os.Mkdir(oldDir, 0700)).To(Succeed())
Expect(os.Mkdir(newDir, 0700)).To(Succeed())
oldFile := filepath.Join(oldDir, "old")
newFile := filepath.Join(newDir, "new")
Expect(os.WriteFile(oldFile, []byte("xx"), 0600)).To(Succeed())
Expect(os.WriteFile(newFile, []byte("xx"), 0600)).To(Succeed())
oldTime := time.Now().Add(-2 * time.Hour)
Expect(os.Chtimes(oldFile, oldTime, oldTime)).To(Succeed())
purgeCacheBySize(tmpDir, "3")
_, err := os.Stat(oldFile)
Expect(os.IsNotExist(err)).To(BeTrue())
_, err = os.Stat(oldDir)
Expect(os.IsNotExist(err)).To(BeTrue())
_, err = os.Stat(newFile)
Expect(err).ToNot(HaveOccurred())
})
It("does nothing when below the size limit", func() {
dir1 := filepath.Join(tmpDir, "a")
dir2 := filepath.Join(tmpDir, "b")
Expect(os.Mkdir(dir1, 0700)).To(Succeed())
Expect(os.Mkdir(dir2, 0700)).To(Succeed())
file1 := filepath.Join(dir1, "f1")
file2 := filepath.Join(dir2, "f2")
Expect(os.WriteFile(file1, []byte("x"), 0600)).To(Succeed())
Expect(os.WriteFile(file2, []byte("x"), 0600)).To(Succeed())
purgeCacheBySize(tmpDir, "10MB")
_, err := os.Stat(file1)
Expect(err).ToNot(HaveOccurred())
_, err = os.Stat(file2)
Expect(err).ToNot(HaveOccurred())
})
})