Files
navidrome/plugins/host_subsonicapi_test.go
Deluan Quintão e8863ed147 feat(plugins): add SubsonicAPI CallRaw, with support for raw=true binary response for host functions (#4982)
* feat: implement raw binary framing for host function responses

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

* feat: add CallRaw method for Subsonic API to handle binary responses

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

* test: add tests for raw=true methods and binary framing generation

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

* fix: improve error message for malformed raw responses to indicate incomplete header

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

* fix: add wasm_import_module attribute for raw methods and improve content-type handling

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-04 15:48:08 -05:00

482 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 = tmpDir
conf.Server.Plugins.AutoReload = false
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
// 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)
}
}