mirror of
https://github.com/navidrome/navidrome.git
synced 2026-06-02 07:01:36 +00:00
* feat(conf): add Dir type with lazy directory creation Introduces the Dir type that wraps a directory path string and defers os.MkdirAll until the first call to Path() or MustPath(), using sync.Once to ensure the creation happens exactly once. Implements fmt.Stringer, encoding.TextMarshaler, and encoding.TextUnmarshaler for config integration. Includes Ginkgo/Gomega tests covering all methods and error paths. * refactor(conf): replace eager dir creation with lazy Dir type Change DataFolder, CacheFolder, Plugins.Folder, and Backup.Path from string to Dir. Remove all os.MkdirAll calls from Load() so directories are created lazily on first Path()/MustPath() call. Artwork folder creation was already handled at point-of-use in image_upload.go. Add SnapshotConfig() to conf package for safe test config save/restore that avoids copying sync.Once inside Dir fields. Fix copy-lock vet warning in nativeapi/config.go by marshalling pointer instead of value. * refactor(conf): migrate tests and db init to lazy Dir type Update all test files to use conf.NewDir() for Dir field assignments. Ensure DataFolder is created lazily when the database is first opened in db.Db(). Remove eager directory creation from conf.Load() tests. * fix(conf): address review findings for Dir type - Use os.ModePerm for DataFolder/CacheFolder (was 0700, should match original behavior). Add NewDirWithPerm for PluginsFolder (0700). - Use Path() instead of MustPath() in db.Prune() to avoid logFatal from background cron job. - Panic on marshal/unmarshal errors in SnapshotConfig (test helper). - Clean up redundant String()/MustPath() calls in plugin manager. - Remove dead code in dir_test.go. Signed-off-by: Deluan <deluan@navidrome.org> * fix(conf): add GoString to Dir for clean config dump output Implement fmt.GoStringer on Dir so pretty.Sprintf shows the path string instead of internal struct fields (sync.Once, perm, err). Also add TODO comment to configtest about removing the indirection. * fix(dir): improve error logging in MustPath method Signed-off-by: Deluan <deluan@navidrome.org> * refactor(tests): remove redundant tests for unwritable DataFolder and CacheFolder Signed-off-by: Deluan <deluan@navidrome.org> * fix(conf): address PR review feedback - Ensure Plugins.Folder always uses 0700, even when user-configured (previously only the derived default got restrictive permissions). - Create LogFile parent directory before opening, so LogFile paths inside a not-yet-created DataFolder work correctly. --------- Signed-off-by: Deluan <deluan@navidrome.org>
481 lines
15 KiB
Go
481 lines
15 KiB
Go
//go:build !windows
|
|
|
|
package plugins
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("SubsonicAPI Host Function", Ordered, func() {
|
|
var (
|
|
manager *Manager
|
|
tmpDir string
|
|
router *fakeSubsonicRouter
|
|
userRepo *tests.MockedUserRepo
|
|
dataStore *tests.MockDataStore
|
|
)
|
|
|
|
BeforeAll(func() {
|
|
var err error
|
|
tmpDir, err = os.MkdirTemp("", "subsonicapi-test-*")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Copy test plugin to temp dir
|
|
srcPath := filepath.Join(testdataDir, "test-subsonicapi-plugin"+PackageExtension)
|
|
destPath := filepath.Join(tmpDir, "test-subsonicapi-plugin"+PackageExtension)
|
|
data, err := os.ReadFile(srcPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = os.WriteFile(destPath, data, 0600)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Setup config
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.Plugins.Enabled = true
|
|
conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
|
|
conf.Server.Plugins.AutoReload = false
|
|
|
|
// Setup mock router and data store
|
|
router = &fakeSubsonicRouter{}
|
|
userRepo = tests.CreateMockUserRepo()
|
|
dataStore = &tests.MockDataStore{MockedUser: userRepo}
|
|
|
|
// Add test users
|
|
_ = userRepo.Put(&model.User{
|
|
ID: "user1",
|
|
UserName: "testuser",
|
|
IsAdmin: false,
|
|
})
|
|
_ = userRepo.Put(&model.User{
|
|
ID: "admin1",
|
|
UserName: "adminuser",
|
|
IsAdmin: true,
|
|
})
|
|
|
|
// Create and configure manager
|
|
manager = &Manager{
|
|
plugins: make(map[string]*plugin),
|
|
ds: dataStore,
|
|
}
|
|
manager.SetSubsonicRouter(router)
|
|
|
|
// Pre-enable the plugin in the mock repo so it loads on startup
|
|
// Compute SHA256 of the plugin file to match what syncPlugins will compute
|
|
pluginPath := filepath.Join(tmpDir, "test-subsonicapi-plugin"+PackageExtension)
|
|
wasmData, err := os.ReadFile(pluginPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
hash := sha256.Sum256(wasmData)
|
|
hashHex := hex.EncodeToString(hash[:])
|
|
|
|
mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo)
|
|
mockPluginRepo.Permitted = true
|
|
enabledPlugin := model.Plugin{
|
|
ID: "test-subsonicapi-plugin",
|
|
Path: pluginPath,
|
|
SHA256: hashHex,
|
|
Enabled: true,
|
|
AllUsers: true, // Allow all users for test plugin
|
|
}
|
|
mockPluginRepo.SetData(model.Plugins{enabledPlugin})
|
|
|
|
// Start the manager
|
|
err = manager.Start(GinkgoT().Context())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
DeferCleanup(func() {
|
|
_ = manager.Stop()
|
|
_ = os.RemoveAll(tmpDir)
|
|
})
|
|
})
|
|
|
|
Describe("Plugin Loading", func() {
|
|
It("loads the plugin with SubsonicAPI permission", func() {
|
|
manager.mu.RLock()
|
|
plugin := manager.plugins["test-subsonicapi-plugin"]
|
|
manager.mu.RUnlock()
|
|
|
|
Expect(plugin).ToNot(BeNil())
|
|
})
|
|
|
|
It("has the correct manifest", func() {
|
|
manager.mu.RLock()
|
|
plugin := manager.plugins["test-subsonicapi-plugin"]
|
|
manager.mu.RUnlock()
|
|
|
|
Expect(plugin).ToNot(BeNil())
|
|
Expect(plugin.manifest.Name).To(Equal("Test SubsonicAPI Plugin"))
|
|
Expect(plugin.manifest.Permissions.Subsonicapi).ToNot(BeNil())
|
|
})
|
|
})
|
|
|
|
Describe("SubsonicAPI Call", func() {
|
|
var plugin *plugin
|
|
|
|
BeforeEach(func() {
|
|
manager.mu.RLock()
|
|
plugin = manager.plugins["test-subsonicapi-plugin"]
|
|
manager.mu.RUnlock()
|
|
Expect(plugin).ToNot(BeNil())
|
|
})
|
|
|
|
It("successfully calls the ping endpoint", func() {
|
|
instance, err := plugin.instance(GinkgoT().Context())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer instance.Close(GinkgoT().Context())
|
|
|
|
exit, output, err := instance.Call("call_subsonic_api", []byte("/ping?u=testuser"))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exit).To(Equal(uint32(0)))
|
|
|
|
// Verify the response contains the expected structure
|
|
var response map[string]any
|
|
err = json.Unmarshal(output, &response)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
subsonicResponse, ok := response["subsonic-response"].(map[string]any)
|
|
Expect(ok).To(BeTrue())
|
|
Expect(subsonicResponse["status"]).To(Equal("ok"))
|
|
})
|
|
|
|
It("adds required parameters (c, f, v) to the request", func() {
|
|
instance, err := plugin.instance(GinkgoT().Context())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer instance.Close(GinkgoT().Context())
|
|
|
|
_, _, err = instance.Call("call_subsonic_api", []byte("/getAlbumList?u=testuser&type=newest"))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Verify the parameters were added
|
|
Expect(router.lastRequest).ToNot(BeNil())
|
|
query := router.lastRequest.URL.Query()
|
|
Expect(query.Get("c")).To(Equal("test-subsonicapi-plugin"))
|
|
Expect(query.Get("f")).To(Equal("json"))
|
|
Expect(query.Get("v")).To(Equal("1.16.1"))
|
|
Expect(query.Get("type")).To(Equal("newest"))
|
|
})
|
|
|
|
It("returns error when username is missing", func() {
|
|
instance, err := plugin.instance(GinkgoT().Context())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer instance.Close(GinkgoT().Context())
|
|
|
|
exit, _, err := instance.Call("call_subsonic_api", []byte("/ping"))
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(exit).To(Equal(uint32(1)))
|
|
Expect(err.Error()).To(ContainSubstring("missing required parameter"))
|
|
})
|
|
})
|
|
|
|
Describe("SubsonicAPI CallRaw", func() {
|
|
var plugin *plugin
|
|
|
|
BeforeEach(func() {
|
|
manager.mu.RLock()
|
|
plugin = manager.plugins["test-subsonicapi-plugin"]
|
|
manager.mu.RUnlock()
|
|
Expect(plugin).ToNot(BeNil())
|
|
})
|
|
|
|
It("successfully calls getCoverArt and returns binary data", func() {
|
|
instance, err := plugin.instance(GinkgoT().Context())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer instance.Close(GinkgoT().Context())
|
|
|
|
exit, output, err := instance.Call("call_subsonic_api_raw", []byte("/getCoverArt?u=testuser&id=al-1"))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exit).To(Equal(uint32(0)))
|
|
|
|
// Parse the metadata response from the test plugin
|
|
var result map[string]any
|
|
err = json.Unmarshal(output, &result)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(result["contentType"]).To(Equal("image/png"))
|
|
Expect(result["size"]).To(BeNumerically("==", len(fakePNGHeader)))
|
|
Expect(result["firstByte"]).To(BeNumerically("==", 0x89)) // PNG magic byte
|
|
})
|
|
|
|
It("does NOT set f=json parameter for raw calls", func() {
|
|
instance, err := plugin.instance(GinkgoT().Context())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer instance.Close(GinkgoT().Context())
|
|
|
|
_, _, err = instance.Call("call_subsonic_api_raw", []byte("/getCoverArt?u=testuser&id=al-1"))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
Expect(router.lastRequest).ToNot(BeNil())
|
|
query := router.lastRequest.URL.Query()
|
|
Expect(query.Get("f")).To(BeEmpty())
|
|
Expect(query.Get("c")).To(Equal("test-subsonicapi-plugin"))
|
|
Expect(query.Get("v")).To(Equal("1.16.1"))
|
|
})
|
|
|
|
It("returns error when username is missing", func() {
|
|
instance, err := plugin.instance(GinkgoT().Context())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer instance.Close(GinkgoT().Context())
|
|
|
|
exit, _, err := instance.Call("call_subsonic_api_raw", []byte("/getCoverArt"))
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(exit).To(Equal(uint32(1)))
|
|
Expect(err.Error()).To(ContainSubstring("missing required parameter"))
|
|
})
|
|
})
|
|
})
|
|
|
|
var _ = Describe("SubsonicAPIService", func() {
|
|
var (
|
|
router *fakeSubsonicRouter
|
|
userRepo *tests.MockedUserRepo
|
|
dataStore *tests.MockDataStore
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
router = &fakeSubsonicRouter{}
|
|
userRepo = tests.CreateMockUserRepo()
|
|
dataStore = &tests.MockDataStore{MockedUser: userRepo}
|
|
|
|
_ = userRepo.Put(&model.User{
|
|
ID: "user1",
|
|
UserName: "testuser",
|
|
IsAdmin: false,
|
|
})
|
|
_ = userRepo.Put(&model.User{
|
|
ID: "admin1",
|
|
UserName: "adminuser",
|
|
IsAdmin: true,
|
|
})
|
|
_ = userRepo.Put(&model.User{
|
|
ID: "user2",
|
|
UserName: "alloweduser",
|
|
IsAdmin: false,
|
|
})
|
|
})
|
|
|
|
Describe("Permission Enforcement", func() {
|
|
Context("with specific user IDs allowed", func() {
|
|
It("blocks users not in the allowed list", func() {
|
|
// allowedUserIDs contains "user2", but testuser is "user1"
|
|
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
|
|
|
|
ctx := GinkgoT().Context()
|
|
_, err := service.Call(ctx, "/ping?u=testuser")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("not authorized"))
|
|
})
|
|
|
|
It("allows users in the allowed list", func() {
|
|
// allowedUserIDs contains "user2" which is "alloweduser"
|
|
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
|
|
|
|
ctx := GinkgoT().Context()
|
|
response, err := service.Call(ctx, "/ping?u=alloweduser")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(response).To(ContainSubstring("ok"))
|
|
})
|
|
|
|
It("blocks admin users when not in allowed list", func() {
|
|
// allowedUserIDs only contains "user1" (testuser), not "admin1"
|
|
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false)
|
|
|
|
ctx := GinkgoT().Context()
|
|
_, err := service.Call(ctx, "/ping?u=adminuser")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("not authorized"))
|
|
})
|
|
|
|
It("allows admin users when in allowed list", func() {
|
|
// allowedUserIDs contains "admin1"
|
|
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"admin1"}, false)
|
|
|
|
ctx := GinkgoT().Context()
|
|
response, err := service.Call(ctx, "/ping?u=adminuser")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(response).To(ContainSubstring("ok"))
|
|
})
|
|
})
|
|
|
|
Context("with allUsers=true", func() {
|
|
It("allows all users regardless of allowed list", func() {
|
|
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
|
|
|
ctx := GinkgoT().Context()
|
|
response, err := service.Call(ctx, "/ping?u=testuser")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(response).To(ContainSubstring("ok"))
|
|
})
|
|
|
|
It("allows admin users when allUsers is true", func() {
|
|
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
|
|
|
ctx := GinkgoT().Context()
|
|
response, err := service.Call(ctx, "/ping?u=adminuser")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(response).To(ContainSubstring("ok"))
|
|
})
|
|
})
|
|
|
|
Context("with no users configured", func() {
|
|
It("returns error when no users are configured", func() {
|
|
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, false)
|
|
|
|
ctx := GinkgoT().Context()
|
|
_, err := service.Call(ctx, "/ping?u=testuser")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("no users configured"))
|
|
})
|
|
|
|
It("returns error for empty user list", func() {
|
|
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{}, false)
|
|
|
|
ctx := GinkgoT().Context()
|
|
_, err := service.Call(ctx, "/ping?u=testuser")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("no users configured"))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("URL Handling", func() {
|
|
It("returns error for missing username parameter", func() {
|
|
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
|
|
|
ctx := GinkgoT().Context()
|
|
_, err := service.Call(ctx, "/ping")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("missing required parameter"))
|
|
})
|
|
|
|
It("returns error for invalid URL", func() {
|
|
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
|
|
|
ctx := GinkgoT().Context()
|
|
_, err := service.Call(ctx, "://invalid")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("invalid URL"))
|
|
})
|
|
|
|
It("extracts endpoint from path correctly", func() {
|
|
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false)
|
|
|
|
ctx := GinkgoT().Context()
|
|
_, err := service.Call(ctx, "/rest/ping.view?u=testuser")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// The endpoint should be extracted as "ping.view"
|
|
Expect(router.lastRequest.URL.Path).To(Equal("/ping.view"))
|
|
})
|
|
})
|
|
|
|
Describe("CallRaw", func() {
|
|
It("returns binary data and content-type", func() {
|
|
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
|
|
|
ctx := GinkgoT().Context()
|
|
contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(contentType).To(Equal("image/png"))
|
|
Expect(data).To(Equal(fakePNGHeader))
|
|
})
|
|
|
|
It("does not set f=json parameter", func() {
|
|
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
|
|
|
ctx := GinkgoT().Context()
|
|
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
Expect(router.lastRequest).ToNot(BeNil())
|
|
query := router.lastRequest.URL.Query()
|
|
Expect(query.Get("f")).To(BeEmpty())
|
|
})
|
|
|
|
It("enforces permission checks", func() {
|
|
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
|
|
|
|
ctx := GinkgoT().Context()
|
|
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("not authorized"))
|
|
})
|
|
|
|
It("returns error when username is missing", func() {
|
|
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
|
|
|
ctx := GinkgoT().Context()
|
|
_, _, err := service.CallRaw(ctx, "/getCoverArt")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("missing required parameter"))
|
|
})
|
|
|
|
It("returns error when router is nil", func() {
|
|
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
|
|
|
|
ctx := GinkgoT().Context()
|
|
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("router not available"))
|
|
})
|
|
|
|
It("returns error for invalid URL", func() {
|
|
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
|
|
|
ctx := GinkgoT().Context()
|
|
_, _, err := service.CallRaw(ctx, "://invalid")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("invalid URL"))
|
|
})
|
|
})
|
|
|
|
Describe("Router Availability", func() {
|
|
It("returns error when router is nil", func() {
|
|
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
|
|
|
|
ctx := GinkgoT().Context()
|
|
_, err := service.Call(ctx, "/ping?u=testuser")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("router not available"))
|
|
})
|
|
})
|
|
})
|
|
|
|
// fakePNGHeader is a minimal PNG file header used in tests.
|
|
var fakePNGHeader = []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
|
|
|
|
// fakeSubsonicRouter is a mock Subsonic router that returns predictable responses.
|
|
type fakeSubsonicRouter struct {
|
|
lastRequest *http.Request
|
|
}
|
|
|
|
func (r *fakeSubsonicRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|
r.lastRequest = req
|
|
|
|
endpoint := path.Base(req.URL.Path)
|
|
switch endpoint {
|
|
case "getCoverArt":
|
|
w.Header().Set("Content-Type", "image/png")
|
|
_, _ = w.Write(fakePNGHeader)
|
|
default:
|
|
// Return a successful ping response
|
|
response := map[string]any{
|
|
"subsonic-response": map[string]any{
|
|
"status": "ok",
|
|
"version": "1.16.1",
|
|
},
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(response)
|
|
}
|
|
}
|