navidrome/plugins/host_subsonicapi_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

219 lines
6.7 KiB
Go

package plugins
import (
"context"
"net/http"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/plugins/host/subsonicapi"
"github.com/navidrome/navidrome/plugins/schema"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("SubsonicAPI Host Service", func() {
var (
service *subsonicAPIServiceImpl
mockRouter http.Handler
userRepo *tests.MockedUserRepo
)
BeforeEach(func() {
// Setup mock datastore with users
userRepo = tests.CreateMockUserRepo()
_ = userRepo.Put(&model.User{UserName: "admin", IsAdmin: true})
_ = userRepo.Put(&model.User{UserName: "user", IsAdmin: false})
ds := &tests.MockDataStore{MockedUser: userRepo}
// Create a mock router
mockRouter = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"subsonic-response":{"status":"ok","version":"1.16.1"}}`))
})
// Create service implementation
service = &subsonicAPIServiceImpl{
pluginID: "test-plugin",
router: mockRouter,
ds: ds,
}
})
// Helper function to create a mock router that captures the request
setupRequestCapture := func() **http.Request {
var capturedRequest *http.Request
mockRouter = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedRequest = r
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
})
service.router = mockRouter
return &capturedRequest
}
Describe("Call", func() {
Context("when subsonic router is available", func() {
It("should process the request successfully", func() {
req := &subsonicapi.CallRequest{
Url: "/rest/ping?u=admin",
}
resp, err := service.Call(context.Background(), req)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.Error).To(BeEmpty())
Expect(resp.Json).To(ContainSubstring("subsonic-response"))
Expect(resp.Json).To(ContainSubstring("ok"))
})
It("should add required parameters to the URL", func() {
capturedRequestPtr := setupRequestCapture()
req := &subsonicapi.CallRequest{
Url: "/rest/getAlbum.view?id=123&u=admin",
}
_, err := service.Call(context.Background(), req)
Expect(err).ToNot(HaveOccurred())
Expect(*capturedRequestPtr).ToNot(BeNil())
query := (*capturedRequestPtr).URL.Query()
Expect(query.Get("c")).To(Equal("test-plugin"))
Expect(query.Get("f")).To(Equal("json"))
Expect(query.Get("v")).To(Equal("1.16.1"))
Expect(query.Get("id")).To(Equal("123"))
Expect(query.Get("u")).To(Equal("admin"))
})
It("should only use path and query from the input URL", func() {
capturedRequestPtr := setupRequestCapture()
req := &subsonicapi.CallRequest{
Url: "https://external.example.com:8080/rest/ping?u=admin",
}
_, err := service.Call(context.Background(), req)
Expect(err).ToNot(HaveOccurred())
Expect(*capturedRequestPtr).ToNot(BeNil())
Expect((*capturedRequestPtr).URL.Path).To(Equal("/ping"))
Expect((*capturedRequestPtr).URL.Host).To(BeEmpty())
Expect((*capturedRequestPtr).URL.Scheme).To(BeEmpty())
})
It("ignores the path prefix in the URL", func() {
capturedRequestPtr := setupRequestCapture()
req := &subsonicapi.CallRequest{
Url: "/basepath/rest/ping?u=admin",
}
_, err := service.Call(context.Background(), req)
Expect(err).ToNot(HaveOccurred())
Expect(*capturedRequestPtr).ToNot(BeNil())
Expect((*capturedRequestPtr).URL.Path).To(Equal("/ping"))
})
It("should set internal authentication with username from 'u' parameter", func() {
capturedRequestPtr := setupRequestCapture()
req := &subsonicapi.CallRequest{
Url: "/rest/ping?u=testuser",
}
_, err := service.Call(context.Background(), req)
Expect(err).ToNot(HaveOccurred())
Expect(*capturedRequestPtr).ToNot(BeNil())
// Verify that internal authentication is set in the context
username, ok := request.InternalAuthFrom((*capturedRequestPtr).Context())
Expect(ok).To(BeTrue())
Expect(username).To(Equal("testuser"))
})
})
Context("when subsonic router is not available", func() {
BeforeEach(func() {
service.router = nil
})
It("should return an error", func() {
req := &subsonicapi.CallRequest{
Url: "/rest/ping?u=admin",
}
resp, err := service.Call(context.Background(), req)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.Error).To(Equal("SubsonicAPI router not available"))
Expect(resp.Json).To(BeEmpty())
})
})
Context("when URL is invalid", func() {
It("should return an error for malformed URLs", func() {
req := &subsonicapi.CallRequest{
Url: "://invalid-url",
}
resp, err := service.Call(context.Background(), req)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.Error).To(ContainSubstring("invalid URL format"))
Expect(resp.Json).To(BeEmpty())
})
It("should return an error when 'u' parameter is missing", func() {
req := &subsonicapi.CallRequest{
Url: "/rest/ping?p=password",
}
resp, err := service.Call(context.Background(), req)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.Error).To(Equal("missing required parameter 'u' (username)"))
Expect(resp.Json).To(BeEmpty())
})
})
Context("permission checks", func() {
It("rejects disallowed username", func() {
service.permissions = parseSubsonicAPIPermissions(&schema.PluginManifestPermissionsSubsonicapi{
Reason: "test",
AllowedUsernames: []string{"user"},
})
resp, err := service.Call(context.Background(), &subsonicapi.CallRequest{Url: "/rest/ping?u=admin"})
Expect(err).ToNot(HaveOccurred())
Expect(resp.Error).To(ContainSubstring("not allowed"))
})
It("rejects admin when allowAdmins is false", func() {
service.permissions = parseSubsonicAPIPermissions(&schema.PluginManifestPermissionsSubsonicapi{Reason: "test"})
resp, err := service.Call(context.Background(), &subsonicapi.CallRequest{Url: "/rest/ping?u=admin"})
Expect(err).ToNot(HaveOccurred())
Expect(resp.Error).To(ContainSubstring("not allowed"))
})
It("allows admin when allowAdmins is true", func() {
service.permissions = parseSubsonicAPIPermissions(&schema.PluginManifestPermissionsSubsonicapi{Reason: "test", AllowAdmins: true})
resp, err := service.Call(context.Background(), &subsonicapi.CallRequest{Url: "/rest/ping?u=admin"})
Expect(err).ToNot(HaveOccurred())
Expect(resp.Error).To(BeEmpty())
})
})
})
})