package nativeapi import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("Plugin API", func() { var ds *tests.MockDataStore var mockManager *tests.MockPluginManager var router http.Handler var adminUser, regularUser model.User var testPlugin1, testPlugin2 model.Plugin BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true ds = &tests.MockDataStore{} mockManager = &tests.MockPluginManager{} auth.Init(ds) nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil, mockManager) router = server.JWTVerifier(nativeRouter) // Create test users adminUser = model.User{ ID: "admin-1", UserName: "admin", Name: "Admin User", IsAdmin: true, NewPassword: "adminpass", } regularUser = model.User{ ID: "user-1", UserName: "regular", Name: "Regular User", IsAdmin: false, NewPassword: "userpass", } // Create test plugins testPlugin1 = model.Plugin{ ID: "test-plugin-1", Path: "/plugins/test1.wasm", Manifest: `{"name":"Test Plugin 1","version":"1.0.0"}`, SHA256: "abc123", Enabled: false, } testPlugin2 = model.Plugin{ ID: "test-plugin-2", Path: "/plugins/test2.wasm", Manifest: `{"name":"Test Plugin 2","version":"2.0.0"}`, Config: `{"setting":"value"}`, SHA256: "def456", Enabled: true, } // Store users in mock datastore Expect(ds.User(GinkgoT().Context()).Put(&adminUser)).To(Succeed()) Expect(ds.User(GinkgoT().Context()).Put(®ularUser)).To(Succeed()) }) Context("when plugins are disabled", func() { BeforeEach(func() { conf.Server.Plugins.Enabled = false }) It("returns 404 for all plugin endpoints", func() { adminToken, err := auth.CreateToken(&adminUser) Expect(err).ToNot(HaveOccurred()) req := httptest.NewRequest("GET", "/plugin", nil) req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) w := httptest.NewRecorder() router.ServeHTTP(w, req) Expect(w.Code).To(Equal(http.StatusNotFound)) }) }) Context("when plugins are enabled", func() { Describe("as admin user", func() { var adminToken string BeforeEach(func() { var err error adminToken, err = auth.CreateToken(&adminUser) Expect(err).ToNot(HaveOccurred()) // Store test plugins as admin ctx := GinkgoT().Context() adminCtx := request.WithUser(ctx, adminUser) Expect(ds.Plugin(adminCtx).Put(&testPlugin1)).To(Succeed()) Expect(ds.Plugin(adminCtx).Put(&testPlugin2)).To(Succeed()) }) Describe("GET /api/plugin", func() { It("returns all plugins", func() { req := httptest.NewRequest("GET", "/plugin", nil) req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) w := httptest.NewRecorder() router.ServeHTTP(w, req) Expect(w.Code).To(Equal(http.StatusOK)) var plugins []model.Plugin err := json.Unmarshal(w.Body.Bytes(), &plugins) Expect(err).ToNot(HaveOccurred()) Expect(plugins).To(HaveLen(2)) }) }) Describe("GET /api/plugin/{id}", func() { It("returns a specific plugin", func() { req := httptest.NewRequest("GET", "/plugin/test-plugin-1", nil) req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) w := httptest.NewRecorder() router.ServeHTTP(w, req) Expect(w.Code).To(Equal(http.StatusOK)) var plugin model.Plugin err := json.Unmarshal(w.Body.Bytes(), &plugin) Expect(err).ToNot(HaveOccurred()) Expect(plugin.ID).To(Equal("test-plugin-1")) Expect(plugin.Path).To(Equal("/plugins/test1.wasm")) }) It("returns 404 for non-existent plugin", func() { req := httptest.NewRequest("GET", "/plugin/non-existent", nil) req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) w := httptest.NewRecorder() router.ServeHTTP(w, req) Expect(w.Code).To(Equal(http.StatusNotFound)) }) }) Describe("PUT /api/plugin/{id}", func() { It("updates plugin enabled state", func() { // Configure mock to update the repo when EnablePlugin is called mockManager.EnablePluginFn = func(ctx context.Context, id string) error { adminCtx := request.WithUser(ctx, adminUser) p, _ := ds.Plugin(adminCtx).Get(id) p.Enabled = true return ds.Plugin(adminCtx).Put(p) } body := bytes.NewBufferString(`{"enabled":true}`) req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) Expect(w.Code).To(Equal(http.StatusOK)) var plugin model.Plugin err := json.Unmarshal(w.Body.Bytes(), &plugin) Expect(err).ToNot(HaveOccurred()) Expect(plugin.Enabled).To(BeTrue()) Expect(mockManager.EnablePluginCalls).To(ContainElement("test-plugin-1")) }) It("updates plugin config with valid JSON", func() { // Configure mock to update the repo when UpdatePluginConfig is called mockManager.UpdatePluginConfigFn = func(ctx context.Context, id, configJSON string) error { adminCtx := request.WithUser(ctx, adminUser) p, _ := ds.Plugin(adminCtx).Get(id) p.Config = configJSON return ds.Plugin(adminCtx).Put(p) } body := bytes.NewBufferString(`{"config":"{\"key\":\"value\"}"}`) req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) Expect(w.Code).To(Equal(http.StatusOK)) var plugin model.Plugin err := json.Unmarshal(w.Body.Bytes(), &plugin) Expect(err).ToNot(HaveOccurred()) Expect(plugin.Config).To(Equal(`{"key":"value"}`)) Expect(mockManager.UpdatePluginConfigCalls).To(HaveLen(1)) Expect(mockManager.UpdatePluginConfigCalls[0].ConfigJSON).To(Equal(`{"key":"value"}`)) }) It("rejects invalid JSON in config field", func() { body := bytes.NewBufferString(`{"config":"not valid json"}`) req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) Expect(w.Code).To(Equal(http.StatusBadRequest)) Expect(w.Body.String()).To(ContainSubstring("Invalid JSON")) }) It("allows empty config", func() { // Configure mock to update the repo when UpdatePluginConfig is called mockManager.UpdatePluginConfigFn = func(ctx context.Context, id, configJSON string) error { adminCtx := request.WithUser(ctx, adminUser) p, _ := ds.Plugin(adminCtx).Get(id) p.Config = configJSON return ds.Plugin(adminCtx).Put(p) } body := bytes.NewBufferString(`{"config":""}`) req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) Expect(w.Code).To(Equal(http.StatusOK)) var plugin model.Plugin err := json.Unmarshal(w.Body.Bytes(), &plugin) Expect(err).ToNot(HaveOccurred()) Expect(plugin.Config).To(Equal("")) }) It("returns 404 for non-existent plugin", func() { body := bytes.NewBufferString(`{"enabled":true}`) req := httptest.NewRequest("PUT", "/plugin/non-existent", body) req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) Expect(w.Code).To(Equal(http.StatusNotFound)) }) It("returns 400 for invalid request body", func() { body := bytes.NewBufferString(`not json`) req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) Expect(w.Code).To(Equal(http.StatusBadRequest)) }) }) }) Describe("as regular user", func() { var userToken string BeforeEach(func() { var err error userToken, err = auth.CreateToken(®ularUser) Expect(err).ToNot(HaveOccurred()) }) It("denies access to GET /api/plugin", func() { req := httptest.NewRequest("GET", "/plugin", nil) req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+userToken) w := httptest.NewRecorder() router.ServeHTTP(w, req) Expect(w.Code).To(Equal(http.StatusForbidden)) }) It("denies access to GET /api/plugin/{id}", func() { req := httptest.NewRequest("GET", "/plugin/test-plugin-1", nil) req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+userToken) w := httptest.NewRecorder() router.ServeHTTP(w, req) Expect(w.Code).To(Equal(http.StatusForbidden)) }) It("denies access to PUT /api/plugin/{id}", func() { body := bytes.NewBufferString(`{"enabled":true}`) req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+userToken) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) Expect(w.Code).To(Equal(http.StatusForbidden)) }) }) Describe("without authentication", func() { It("denies access to plugin endpoints", func() { req := httptest.NewRequest("GET", "/plugin", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) Expect(w.Code).To(Equal(http.StatusUnauthorized)) }) }) }) })