mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-04 06:35:52 +00:00
Allow administrators to disable playlist cover art upload/removal for non-admin users via the new EnableCoverArtUpload config option (default: true). - Guard uploadPlaylistImage and deletePlaylistImage endpoints (403 for non-admin when disabled) - Set CoverArtRole in Subsonic GetUser/GetUsers responses based on config and admin status - Pass config to frontend and conditionally hide upload/remove UI controls - Admins always retain upload capability regardless of setting
239 lines
7.0 KiB
Go
239 lines
7.0 KiB
Go
package nativeapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"time"
|
|
|
|
"github.com/deluan/rest"
|
|
"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/core/playlists"
|
|
"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("Playlist Image Endpoints", func() {
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
})
|
|
|
|
DescribeTable("uploadPlaylistImage guard",
|
|
func(enableCoverArtUpload, isAdmin bool, expectedStatus int) {
|
|
conf.Server.EnableCoverArtUpload = enableCoverArtUpload
|
|
handler := uploadPlaylistImage(&mockPlaylistsService{})
|
|
|
|
req := httptest.NewRequest("POST", "/playlist/pls-1/image", nil)
|
|
ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "user-1", IsAdmin: isAdmin})
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
Expect(w.Code).To(Equal(expectedStatus))
|
|
},
|
|
Entry("enabled, regular user passes guard", true, false, http.StatusBadRequest),
|
|
Entry("enabled, admin passes guard", true, true, http.StatusBadRequest),
|
|
Entry("disabled, admin passes guard", false, true, http.StatusBadRequest),
|
|
Entry("disabled, regular user is forbidden", false, false, http.StatusForbidden),
|
|
)
|
|
|
|
DescribeTable("deletePlaylistImage guard",
|
|
func(enableCoverArtUpload, isAdmin bool, expectedStatus int) {
|
|
conf.Server.EnableCoverArtUpload = enableCoverArtUpload
|
|
handler := deletePlaylistImage(&mockPlaylistsService{})
|
|
|
|
req := httptest.NewRequest("DELETE", "/playlist/pls-1/image", nil)
|
|
ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "user-1", IsAdmin: isAdmin})
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
Expect(w.Code).To(Equal(expectedStatus))
|
|
},
|
|
Entry("enabled, regular user passes guard", true, false, http.StatusNotFound),
|
|
Entry("enabled, admin passes guard", true, true, http.StatusNotFound),
|
|
Entry("disabled, admin passes guard", false, true, http.StatusNotFound),
|
|
Entry("disabled, regular user is forbidden", false, false, http.StatusForbidden),
|
|
)
|
|
})
|
|
|
|
var _ = Describe("Playlist Tracks Endpoint", func() {
|
|
var (
|
|
router http.Handler
|
|
plsSvc *mockPlaylistsService
|
|
userRepo *tests.MockedUserRepo
|
|
w *httptest.ResponseRecorder
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.SessionTimeout = time.Minute
|
|
|
|
plsSvc = &mockPlaylistsService{}
|
|
userRepo = tests.CreateMockUserRepo()
|
|
|
|
ds := &tests.MockDataStore{
|
|
MockedUser: userRepo,
|
|
MockedProperty: &tests.MockedPropertyRepo{},
|
|
}
|
|
|
|
auth.Init(ds)
|
|
|
|
testUser := model.User{
|
|
ID: "user-1",
|
|
UserName: "testuser",
|
|
Name: "Test User",
|
|
IsAdmin: false,
|
|
NewPassword: "testpass",
|
|
}
|
|
err := userRepo.Put(&testUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil)
|
|
router = server.JWTVerifier(nativeRouter)
|
|
w = httptest.NewRecorder()
|
|
})
|
|
|
|
createAuthenticatedRequest := func(method, path string) *http.Request {
|
|
req := httptest.NewRequest(method, path, nil)
|
|
testUser := model.User{ID: "user-1", UserName: "testuser"}
|
|
token, err := auth.CreateToken(&testUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
|
|
return req
|
|
}
|
|
|
|
Describe("GET /playlist/{playlistId}/tracks", func() {
|
|
It("returns 404 when playlist does not exist", func() {
|
|
req := createAuthenticatedRequest("GET", "/playlist/non-existent/tracks")
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("returns tracks when playlist exists", func() {
|
|
plsSvc.tracksRepo = &mockPlaylistTrackRepo{
|
|
tracks: model.PlaylistTracks{
|
|
{ID: "1", MediaFileID: "mf-1", PlaylistID: "pls-1"},
|
|
{ID: "2", MediaFileID: "mf-2", PlaylistID: "pls-1"},
|
|
},
|
|
}
|
|
|
|
req := createAuthenticatedRequest("GET", "/playlist/pls-1/tracks")
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
|
|
var response []model.PlaylistTrack
|
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(response).To(HaveLen(2))
|
|
Expect(response[0].ID).To(Equal("1"))
|
|
Expect(response[1].ID).To(Equal("2"))
|
|
})
|
|
})
|
|
|
|
Describe("GET /playlist/{playlistId}/tracks/{id}", func() {
|
|
It("returns 404 when playlist does not exist", func() {
|
|
req := createAuthenticatedRequest("GET", "/playlist/non-existent/tracks/1")
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("returns the track when playlist exists", func() {
|
|
plsSvc.tracksRepo = &mockPlaylistTrackRepo{
|
|
tracks: model.PlaylistTracks{
|
|
{ID: "1", MediaFileID: "mf-1", PlaylistID: "pls-1"},
|
|
},
|
|
}
|
|
|
|
req := createAuthenticatedRequest("GET", "/playlist/pls-1/tracks/1")
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
|
|
var response model.PlaylistTrack
|
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(response.ID).To(Equal("1"))
|
|
Expect(response.MediaFileID).To(Equal("mf-1"))
|
|
})
|
|
|
|
It("returns 404 when track does not exist in playlist", func() {
|
|
plsSvc.tracksRepo = &mockPlaylistTrackRepo{
|
|
tracks: model.PlaylistTracks{},
|
|
}
|
|
|
|
req := createAuthenticatedRequest("GET", "/playlist/pls-1/tracks/999")
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
})
|
|
})
|
|
|
|
type mockPlaylistTrackRepo struct {
|
|
model.PlaylistTrackRepository
|
|
tracks model.PlaylistTracks
|
|
}
|
|
|
|
func (m *mockPlaylistTrackRepo) Count(...rest.QueryOptions) (int64, error) {
|
|
return int64(len(m.tracks)), nil
|
|
}
|
|
|
|
func (m *mockPlaylistTrackRepo) ReadAll(...rest.QueryOptions) (any, error) {
|
|
return m.tracks, nil
|
|
}
|
|
|
|
func (m *mockPlaylistTrackRepo) EntityName() string {
|
|
return "playlist_track"
|
|
}
|
|
|
|
func (m *mockPlaylistTrackRepo) NewInstance() any {
|
|
return &model.PlaylistTrack{}
|
|
}
|
|
|
|
func (m *mockPlaylistTrackRepo) Read(id string) (any, error) {
|
|
for _, t := range m.tracks {
|
|
if t.ID == id {
|
|
return &t, nil
|
|
}
|
|
}
|
|
return nil, rest.ErrNotFound
|
|
}
|
|
|
|
type mockPlaylistsService struct {
|
|
playlists.Playlists
|
|
tracksRepo rest.Repository
|
|
removeImageFn func(ctx context.Context, id string) error
|
|
setImageFn func(ctx context.Context, id string, reader io.Reader, ext string) error
|
|
}
|
|
|
|
func (m *mockPlaylistsService) RemoveImage(ctx context.Context, id string) error {
|
|
if m.removeImageFn != nil {
|
|
return m.removeImageFn(ctx, id)
|
|
}
|
|
return model.ErrNotFound
|
|
}
|
|
|
|
func (m *mockPlaylistsService) SetImage(ctx context.Context, id string, reader io.Reader, ext string) error {
|
|
if m.setImageFn != nil {
|
|
return m.setImageFn(ctx, id, reader, ext)
|
|
}
|
|
return model.ErrNotFound
|
|
}
|
|
|
|
func (m *mockPlaylistsService) TracksRepository(_ context.Context, _ string, _ bool) rest.Repository {
|
|
return m.tracksRepo
|
|
}
|