mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-29 22:47:48 +01:00
feat(api): implement library management and user-library association endpoints
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
a736d79985
commit
403fe19e1a
7 changed files with 952 additions and 8 deletions
337
server/nativeapi/library.go
Normal file
337
server/nativeapi/library.go
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
package nativeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
// Library management endpoints (admin only)
|
||||
func (n *Router) addLibraryRoute(r chi.Router) {
|
||||
r.Route("/library", func(r chi.Router) {
|
||||
r.Use(adminOnlyMiddleware)
|
||||
r.Get("/", getLibraries(n.ds))
|
||||
r.Post("/", createLibrary(n.ds))
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(parseIDMiddleware)
|
||||
r.Get("/", getLibrary(n.ds))
|
||||
r.Put("/", updateLibrary(n.ds))
|
||||
r.Delete("/", deleteLibrary(n.ds))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// User-library association endpoints (admin only)
|
||||
func (n *Router) addUserLibraryRoute(r chi.Router) {
|
||||
r.Route("/user/{id}/library", func(r chi.Router) {
|
||||
r.Use(adminOnlyMiddleware)
|
||||
r.Use(parseUserIDMiddleware)
|
||||
r.Get("/", getUserLibraries(n.ds))
|
||||
r.Put("/", setUserLibraries(n.ds))
|
||||
})
|
||||
}
|
||||
|
||||
// Middleware to ensure only admin users can access endpoints
|
||||
func adminOnlyMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := request.UserFrom(r.Context())
|
||||
if !ok || !user.IsAdmin {
|
||||
http.Error(w, "Access denied: admin privileges required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Middleware to parse library ID from URL
|
||||
func parseIDMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid library ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), "libraryID", id)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// Middleware to parse user ID from URL
|
||||
func parseUserIDMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "id")
|
||||
if userID == "" {
|
||||
http.Error(w, "Invalid user ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), "userID", userID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// Library CRUD handlers
|
||||
|
||||
func getLibraries(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
libraries, err := ds.Library(r.Context()).GetAll()
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error getting libraries", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(libraries); err != nil {
|
||||
log.Error(r.Context(), "Error encoding libraries response", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getLibrary(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
libraryID := r.Context().Value("libraryID").(int)
|
||||
|
||||
library, err := ds.Library(r.Context()).Get(libraryID)
|
||||
if err != nil {
|
||||
if err == model.ErrNotFound {
|
||||
http.Error(w, "Library not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Error(r.Context(), "Error getting library", "libraryID", libraryID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(library); err != nil {
|
||||
log.Error(r.Context(), "Error encoding library response", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createLibrary(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var library model.Library
|
||||
if err := json.NewDecoder(r.Body).Decode(&library); err != nil {
|
||||
log.Error(r.Context(), "Error decoding library request", err)
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if library.Name == "" {
|
||||
http.Error(w, "Library name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if library.Path == "" {
|
||||
http.Error(w, "Library path is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ds.Library(r.Context()).Put(&library); err != nil {
|
||||
log.Error(r.Context(), "Error creating library", err)
|
||||
http.Error(w, "Failed to create library", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(library); err != nil {
|
||||
log.Error(r.Context(), "Error encoding library response", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateLibrary(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
libraryID := r.Context().Value("libraryID").(int)
|
||||
|
||||
var library model.Library
|
||||
if err := json.NewDecoder(r.Body).Decode(&library); err != nil {
|
||||
log.Error(r.Context(), "Error decoding library request", err)
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure the ID matches the URL parameter
|
||||
library.ID = libraryID
|
||||
|
||||
// Validate required fields
|
||||
if library.Name == "" {
|
||||
http.Error(w, "Library name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if library.Path == "" {
|
||||
http.Error(w, "Library path is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ds.Library(r.Context()).Put(&library); err != nil {
|
||||
if err == model.ErrNotFound {
|
||||
http.Error(w, "Library not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Error(r.Context(), "Error updating library", "libraryID", libraryID, err)
|
||||
http.Error(w, "Failed to update library", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(library); err != nil {
|
||||
log.Error(r.Context(), "Error encoding library response", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteLibrary(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
libraryID := r.Context().Value("libraryID").(int)
|
||||
|
||||
if err := ds.Library(r.Context()).Delete(libraryID); err != nil {
|
||||
if err == model.ErrNotFound {
|
||||
http.Error(w, "Library not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Error(r.Context(), "Error deleting library", "libraryID", libraryID, err)
|
||||
http.Error(w, "Failed to delete library", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// User-library association handlers
|
||||
|
||||
func getUserLibraries(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Context().Value("userID").(string)
|
||||
|
||||
// Verify user exists
|
||||
if _, err := ds.User(r.Context()).Get(userID); err != nil {
|
||||
if err == model.ErrNotFound {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Error(r.Context(), "Error getting user", "userID", userID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
libraries, err := ds.User(r.Context()).GetUserLibraries(userID)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error getting user libraries", "userID", userID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(libraries); err != nil {
|
||||
log.Error(r.Context(), "Error encoding user libraries response", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setUserLibraries(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Context().Value("userID").(string)
|
||||
|
||||
// Verify user exists
|
||||
user, err := ds.User(r.Context()).Get(userID)
|
||||
if err != nil {
|
||||
if err == model.ErrNotFound {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Error(r.Context(), "Error getting user", "userID", userID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var request struct {
|
||||
LibraryIDs []int `json:"libraryIds"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
log.Error(r.Context(), "Error decoding request", err)
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Admin users get all libraries automatically - don't allow manual assignment
|
||||
if user.IsAdmin {
|
||||
http.Error(w, "Cannot manually assign libraries to admin users", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate library IDs exist
|
||||
if len(request.LibraryIDs) > 0 {
|
||||
// Use CountAll with IN filter to efficiently check if all library IDs exist
|
||||
count, err := ds.Library(r.Context()).CountAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"id": request.LibraryIDs},
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error counting libraries", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if int(count) != len(request.LibraryIDs) {
|
||||
// Some library IDs don't exist - find which ones for better error message
|
||||
existingLibraries, err := ds.Library(r.Context()).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"id": request.LibraryIDs},
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error getting all libraries", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
libraryMap := make(map[int]bool)
|
||||
for _, lib := range existingLibraries {
|
||||
libraryMap[lib.ID] = true
|
||||
}
|
||||
|
||||
for _, libID := range request.LibraryIDs {
|
||||
if !libraryMap[libID] {
|
||||
http.Error(w, fmt.Sprintf("Library ID %d does not exist", libID), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Regular users must have at least one library
|
||||
if len(request.LibraryIDs) == 0 {
|
||||
http.Error(w, "At least one library must be assigned to non-admin users", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ds.User(r.Context()).SetUserLibraries(userID, request.LibraryIDs); err != nil {
|
||||
log.Error(r.Context(), "Error setting user libraries", "userID", userID, err)
|
||||
http.Error(w, "Failed to set user libraries", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return updated user libraries
|
||||
libraries, err := ds.User(r.Context()).GetUserLibraries(userID)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error getting updated user libraries", "userID", userID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(libraries); err != nil {
|
||||
log.Error(r.Context(), "Error encoding user libraries response", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
442
server/nativeapi/library_test.go
Normal file
442
server/nativeapi/library_test.go
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
package nativeapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"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/server"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Library API", func() {
|
||||
var ds model.DataStore
|
||||
var router http.Handler
|
||||
var adminUser, regularUser model.User
|
||||
var library1, library2 model.Library
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, 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 libraries
|
||||
library1 = model.Library{
|
||||
ID: 1,
|
||||
Name: "Test Library 1",
|
||||
Path: "/music/library1",
|
||||
}
|
||||
library2 = model.Library{
|
||||
ID: 2,
|
||||
Name: "Test Library 2",
|
||||
Path: "/music/library2",
|
||||
}
|
||||
|
||||
// Store in mock datastore
|
||||
Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed())
|
||||
Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed())
|
||||
Expect(ds.Library(context.TODO()).Put(&library1)).To(Succeed())
|
||||
Expect(ds.Library(context.TODO()).Put(&library2)).To(Succeed())
|
||||
})
|
||||
|
||||
Describe("Library CRUD Operations", func() {
|
||||
Context("as admin user", func() {
|
||||
var adminToken string
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
adminToken, err = auth.CreateToken(&adminUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
Describe("GET /api/library", func() {
|
||||
It("returns all libraries", func() {
|
||||
req := createAuthenticatedRequest("GET", "/library", nil, adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var libraries []model.Library
|
||||
err := json.Unmarshal(w.Body.Bytes(), &libraries)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(libraries).To(HaveLen(2))
|
||||
Expect(libraries[0].Name).To(Equal("Test Library 1"))
|
||||
Expect(libraries[1].Name).To(Equal("Test Library 2"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GET /api/library/{id}", func() {
|
||||
It("returns a specific library", func() {
|
||||
req := createAuthenticatedRequest("GET", "/library/1", nil, adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var library model.Library
|
||||
err := json.Unmarshal(w.Body.Bytes(), &library)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(library.Name).To(Equal("Test Library 1"))
|
||||
Expect(library.Path).To(Equal("/music/library1"))
|
||||
})
|
||||
|
||||
It("returns 404 for non-existent library", func() {
|
||||
req := createAuthenticatedRequest("GET", "/library/999", nil, adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("returns 400 for invalid library ID", func() {
|
||||
req := createAuthenticatedRequest("GET", "/library/invalid", nil, adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("POST /api/library", func() {
|
||||
It("creates a new library", func() {
|
||||
newLibrary := model.Library{
|
||||
Name: "New Library",
|
||||
Path: "/music/new",
|
||||
}
|
||||
body, _ := json.Marshal(newLibrary)
|
||||
req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusCreated))
|
||||
|
||||
var created model.Library
|
||||
err := json.Unmarshal(w.Body.Bytes(), &created)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(created.Name).To(Equal("New Library"))
|
||||
Expect(created.Path).To(Equal("/music/new"))
|
||||
})
|
||||
|
||||
It("validates required fields", func() {
|
||||
invalidLibrary := model.Library{
|
||||
Name: "", // Missing name
|
||||
Path: "/music/invalid",
|
||||
}
|
||||
body, _ := json.Marshal(invalidLibrary)
|
||||
req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(w.Body.String()).To(ContainSubstring("Library name is required"))
|
||||
})
|
||||
|
||||
It("validates path field", func() {
|
||||
invalidLibrary := model.Library{
|
||||
Name: "Valid Name",
|
||||
Path: "", // Missing path
|
||||
}
|
||||
body, _ := json.Marshal(invalidLibrary)
|
||||
req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(w.Body.String()).To(ContainSubstring("Library path is required"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PUT /api/library/{id}", func() {
|
||||
It("updates an existing library", func() {
|
||||
updatedLibrary := model.Library{
|
||||
Name: "Updated Library 1",
|
||||
Path: "/music/updated",
|
||||
}
|
||||
body, _ := json.Marshal(updatedLibrary)
|
||||
req := createAuthenticatedRequest("PUT", "/library/1", bytes.NewBuffer(body), adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var updated model.Library
|
||||
err := json.Unmarshal(w.Body.Bytes(), &updated)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(updated.ID).To(Equal(1))
|
||||
Expect(updated.Name).To(Equal("Updated Library 1"))
|
||||
Expect(updated.Path).To(Equal("/music/updated"))
|
||||
})
|
||||
|
||||
It("validates required fields on update", func() {
|
||||
invalidLibrary := model.Library{
|
||||
Name: "",
|
||||
Path: "/music/path",
|
||||
}
|
||||
body, _ := json.Marshal(invalidLibrary)
|
||||
req := createAuthenticatedRequest("PUT", "/library/1", bytes.NewBuffer(body), adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DELETE /api/library/{id}", func() {
|
||||
It("deletes an existing library", func() {
|
||||
req := createAuthenticatedRequest("DELETE", "/library/1", nil, adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNoContent))
|
||||
})
|
||||
|
||||
It("returns 404 for non-existent library", func() {
|
||||
req := createAuthenticatedRequest("DELETE", "/library/999", nil, adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("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 library management endpoints", func() {
|
||||
endpoints := []string{
|
||||
"GET /library",
|
||||
"POST /library",
|
||||
"GET /library/1",
|
||||
"PUT /library/1",
|
||||
"DELETE /library/1",
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
parts := Split(endpoint, " ")
|
||||
method, path := parts[0], parts[1]
|
||||
|
||||
req := createAuthenticatedRequest(method, path, nil, userToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Context("without authentication", func() {
|
||||
It("denies access to library management endpoints", func() {
|
||||
req := createUnauthenticatedRequest("GET", "/library", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("User-Library Association Operations", func() {
|
||||
Context("as admin user", func() {
|
||||
var adminToken string
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
adminToken, err = auth.CreateToken(&adminUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
Describe("GET /api/user/{id}/library", func() {
|
||||
It("returns user's libraries", func() {
|
||||
// Set up user libraries
|
||||
err := ds.User(context.TODO()).SetUserLibraries(regularUser.ID, []int{1, 2})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var libraries []model.Library
|
||||
err = json.Unmarshal(w.Body.Bytes(), &libraries)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(libraries).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("returns 404 for non-existent user", func() {
|
||||
req := createAuthenticatedRequest("GET", "/user/non-existent/library", nil, adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PUT /api/user/{id}/library", func() {
|
||||
It("sets user's libraries", func() {
|
||||
request := map[string][]int{
|
||||
"libraryIds": {1, 2},
|
||||
}
|
||||
body, _ := json.Marshal(request)
|
||||
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var libraries []model.Library
|
||||
err := json.Unmarshal(w.Body.Bytes(), &libraries)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(libraries).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("validates library IDs exist", func() {
|
||||
request := map[string][]int{
|
||||
"libraryIds": {999}, // Non-existent library
|
||||
}
|
||||
body, _ := json.Marshal(request)
|
||||
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(w.Body.String()).To(ContainSubstring("Library ID 999 does not exist"))
|
||||
})
|
||||
|
||||
It("requires at least one library for regular users", func() {
|
||||
request := map[string][]int{
|
||||
"libraryIds": {}, // Empty libraries
|
||||
}
|
||||
body, _ := json.Marshal(request)
|
||||
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(w.Body.String()).To(ContainSubstring("At least one library must be assigned"))
|
||||
})
|
||||
|
||||
It("prevents manual assignment to admin users", func() {
|
||||
request := map[string][]int{
|
||||
"libraryIds": {1},
|
||||
}
|
||||
body, _ := json.Marshal(request)
|
||||
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", adminUser.ID), bytes.NewBuffer(body), adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(w.Body.String()).To(ContainSubstring("Cannot manually assign libraries to admin users"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("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 user-library association endpoints", func() {
|
||||
req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, userToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
|
||||
func createAuthenticatedRequest(method, path string, body *bytes.Buffer, token string) *http.Request {
|
||||
if body == nil {
|
||||
body = &bytes.Buffer{}
|
||||
}
|
||||
req := httptest.NewRequest(method, path, body)
|
||||
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
}
|
||||
|
||||
func createUnauthenticatedRequest(method, path string, body *bytes.Buffer) *http.Request {
|
||||
if body == nil {
|
||||
body = &bytes.Buffer{}
|
||||
}
|
||||
req := httptest.NewRequest(method, path, body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
}
|
||||
|
||||
// Helper function to split strings
|
||||
func Split(s, sep string) []string {
|
||||
var result []string
|
||||
start := 0
|
||||
for i, r := range s {
|
||||
if string(r) == sep {
|
||||
result = append(result, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
result = append(result, s[start:])
|
||||
return result
|
||||
}
|
||||
|
|
@ -66,6 +66,8 @@ func (n *Router) routes() http.Handler {
|
|||
n.addConfigRoute(r)
|
||||
n.addKeepAliveRoute(r)
|
||||
n.addInsightsRoute(r)
|
||||
n.addLibraryRoute(r)
|
||||
n.addUserLibraryRoute(r)
|
||||
})
|
||||
|
||||
return r
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue