navidrome/server/nativeapi/plugin_test.go
Deluan Quintão b76ae14286
feat(jellyfin): add Quick Connect sign-in (#6174)
* feat(jellyfin): add Quick Connect sign-in

Jellyfin clients can now sign in without a password: the client shows a
6-digit code, a signed-in user approves it, and the client redeems a secret
for its access token.

- core/quickconnect: in-memory store shared by both routers through wire.
  Codes expire after 10 minutes; a secret redeems only once (Jellyfin
  allows repeats for 10 minutes); at most 1000 pending requests.
- Jellyfin API: Initiate, Connect, Authorize and AuthenticateWithQuickConnect.
  Admins may approve for another user via UserId, like Swiftfin's admin page.
  Initiate and redeem share the login rate limiter; Connect does not, since
  Finamp and Streamyfin poll it every second.
- Web UI: a Quick Connect item in the user menu looks up the code and shows
  the app and device before approving, so a user can't be tricked into
  approving an unknown device blindly.
- Jellyfin.QuickConnect option, on by default like Jellyfin. It only matters
  when the Jellyfin API is enabled.

* refactor(jellyfin): tidy Quick Connect naming and route guards

Group the Quick Connect routes under one requireQuickConnect guard, make the
request's device a named field so req.Device.ID can't be mistaken for a
request id, and rename the web API response type to quickConnectDevice.

* refactor(jellyfin): remove duplicated Jellyfin date formatting function

* test(jellyfin): set play count and starred in the song fixture literal

* refactor(jellyfin): inline the Quick Connect redeem body and use the shared date helper

* fix(jellyfin): bound the client fields Quick Connect keeps in memory

Initiate is unauthenticated and keeps the Client, Device, DeviceId and Version
header fields for up to ten minutes. With no header size limit, each pending
request could hold about 1 MB, and even a short field kept the whole header
alive because the parsed values are substrings of it. Reject fields over 512
bytes and copy the stored values.

Also answer 500 instead of 401 when the redeem user lookup fails for a reason
other than the user being gone.

* fix(jellyfin): rate-limit Quick Connect code approval

Any signed-in user could try codes without limit on the Jellyfin Authorize
endpoint and the web UI lookup/authorize endpoints, and so could approve
another person's pending device for their own account. Apply the same per-IP
limiter as the login (AuthRequestLimit/AuthWindowLength) to both surfaces.
2026-09-19 14:57:01 -04:00

488 lines
16 KiB
Go

package nativeapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"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/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.EnableSharing = false
conf.Server.Plugins.Enabled = true
ds = &tests.MockDataStore{}
mockManager = &tests.MockPluginManager{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil, nil)
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(&regularUser)).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("updates users field", func() {
// Configure mock to update the repo when UpdatePluginUsers is called
mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error {
adminCtx := request.WithUser(ctx, adminUser)
p, _ := ds.Plugin(adminCtx).Get(id)
p.Users = usersJSON
p.AllUsers = allUsers
return ds.Plugin(adminCtx).Put(p)
}
body := bytes.NewBufferString(`{"users":"[\"user1\",\"user2\"]"}`)
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.Users).To(Equal(`["user1","user2"]`))
Expect(mockManager.UpdatePluginUsersCalls).To(HaveLen(1))
Expect(mockManager.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["user1","user2"]`))
})
It("updates allUsers field", func() {
// Configure mock to update the repo when UpdatePluginUsers is called
mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error {
adminCtx := request.WithUser(ctx, adminUser)
p, _ := ds.Plugin(adminCtx).Get(id)
p.Users = usersJSON
p.AllUsers = allUsers
return ds.Plugin(adminCtx).Put(p)
}
body := bytes.NewBufferString(`{"allUsers":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.AllUsers).To(BeTrue())
Expect(mockManager.UpdatePluginUsersCalls).To(HaveLen(1))
Expect(mockManager.UpdatePluginUsersCalls[0].AllUsers).To(BeTrue())
})
It("updates both users and allUsers fields together", func() {
// Configure mock to update the repo when UpdatePluginUsers is called
mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error {
adminCtx := request.WithUser(ctx, adminUser)
p, _ := ds.Plugin(adminCtx).Get(id)
p.Users = usersJSON
p.AllUsers = allUsers
return ds.Plugin(adminCtx).Put(p)
}
body := bytes.NewBufferString(`{"users":"[\"user1\"]","allUsers":false}`)
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.Users).To(Equal(`["user1"]`))
Expect(plugin.AllUsers).To(BeFalse())
Expect(mockManager.UpdatePluginUsersCalls).To(HaveLen(1))
})
It("rejects invalid JSON in users field", func() {
body := bytes.NewBufferString(`{"users":"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 users", func() {
// Configure mock to update the repo when UpdatePluginUsers is called
mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error {
adminCtx := request.WithUser(ctx, adminUser)
p, _ := ds.Plugin(adminCtx).Get(id)
p.Users = usersJSON
p.AllUsers = allUsers
return ds.Plugin(adminCtx).Put(p)
}
body := bytes.NewBufferString(`{"users":""}`)
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.Users).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("POST /api/plugin/rescan", func() {
It("triggers plugin rescan", func() {
req := httptest.NewRequest("POST", "/plugin/rescan", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mockManager.RescanPluginsCalls).To(Equal(1))
})
It("returns error when rescan fails", func() {
mockManager.RescanError = errors.New("folder not configured")
req := httptest.NewRequest("POST", "/plugin/rescan", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
Expect(w.Body.String()).To(ContainSubstring("folder not configured"))
})
})
})
Describe("as regular user", func() {
var userToken string
BeforeEach(func() {
var err error
userToken, err = auth.CreateToken(&regularUser)
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))
})
It("denies access to POST /api/plugin/rescan", func() {
req := httptest.NewRequest("POST", "/plugin/rescan", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+userToken)
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))
})
})
})
})