mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-11 11:57:12 +02:00
* fix: return 404 instead of 500 for missing native API resources The deluan/rest controller only maps rest.ErrNotFound to 404, comparing with ==. Most repositories return model.ErrNotFound, which had the same message but was a different value, so requesting a missing playlist, album, artist, song, radio, player, transcoding or library returned 500. This also applied to other users' private playlists. Make model.ErrNotFound the same value as rest.ErrNotFound. This fixes every REST route at once, with no per-route wrapping. errors.Is checks against either error keep working, and nothing wraps model.ErrNotFound before it reaches the controller. Fixes #6130 * fix(radio): return not found when deleting a missing radio station radioRepository.Delete used the shared delete helper, which never reports a missing row because SQL DELETE on zero rows is not an error. Deleting an unknown id silently succeeded: DELETE /api/radio/{id} returned 200, and the Subsonic deleteInternetRadioStation endpoint returned ok. Delete now checks the affected row count and returns model.ErrNotFound when nothing was deleted. The native API returns 404, and deleteInternetRadioStation returns error 70 (data not found). This matches Subsonic 6.1.6, gonic (both verified live) and Ampache (verified in source). Airsonic-Advanced does not implement this endpoint. The shared delete helper is unchanged, as several callers rely on deletes of absent rows succeeding. * fix(ui): return 404 for missing files that are not missing or do not exist missingRepository.Read filtered media files by bare "id" and "missing" columns. The media file query joins the library table, so SQLite rejected the query as ambiguous and GET /api/missing/{id} returned 500. Read now loads the file with MediaFileRepository.Get, which qualifies the column, and returns not found when the file does not exist or is not marked missing. * fix: report missing rows on single-item deletes and adopt deluan/rest errors.Is Bump github.com/deluan/rest to the version whose controller matches errors with errors.Is and errors.As. model.ErrNotFound stays the same value as rest.ErrNotFound, so the many hand-written conversions from model.ErrNotFound to rest.ErrNotFound in repositories, core services and test mocks did nothing. Remove them, along with the duplicate rest.ErrNotFound check in the Subsonic error mapper. Mappings from model.ErrNotAuthorized stay, as those are different errors. User and transcoding deletes had the same silent success as radio: the shared delete helper never reports a missing row, so their not-found checks never fired and DELETE /api/user/{id} and /api/transcoding/{id} returned 200 for unknown ids. Add deleteByID, which returns model.ErrNotFound when no row matched, and use it for radio, user and transcoding. Also drop the dead sql.ErrNoRows branch from delete, since a DELETE never returns it. The Subsonic deleteUser endpoint is not implemented (501), so this does not change the Subsonic API. Plugin deletes keep the silent helper: there is no REST route for them, and the plugin manager only deletes rows it just read. * chore: drop ErrNotFound comment and its identity test The alias to rest.ErrNotFound is self-explanatory, and the identity test only restated the declaration. * refactor: alias model.ErrNotAuthorized to rest.ErrPermissionDenied Like ErrNotFound, make model.ErrNotAuthorized the same value as the rest library's error, so REST endpoints map it to 403 directly. This removes the ErrNotAuthorized to rest.ErrPermissionDenied mappings in the library and playlist REST adapters and the duplicate check in the Subsonic error mapper. Handlers that check model.ErrNotAuthorized now also recognize rest.ErrPermissionDenied returned by repositories, so writePlaylistError, the image upload handlers and the public share handler return 403 for it instead of their fallback status. The error message changes from "not authorized" to "permission denied".
851 lines
28 KiB
Go
851 lines
28 KiB
Go
package persistence
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"slices"
|
|
"sync"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/deluan/rest"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/id"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/tests"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("UserRepository", func() {
|
|
var repo model.UserRepository
|
|
|
|
BeforeEach(func() {
|
|
repo = NewUserRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder())
|
|
})
|
|
|
|
Describe("Put/Get/FindByUsername", func() {
|
|
usr := model.User{
|
|
ID: "123",
|
|
UserName: "AdMiN",
|
|
Name: "Admin",
|
|
Email: "admin@admin.com",
|
|
NewPassword: "wordpass",
|
|
IsAdmin: true,
|
|
}
|
|
It("saves the user to the DB", func() {
|
|
Expect(repo.Put(&usr)).To(BeNil())
|
|
})
|
|
It("returns the newly created user", func() {
|
|
actual, err := repo.Get("123")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(actual.Name).To(Equal("Admin"))
|
|
})
|
|
It("find the user by case-insensitive username", func() {
|
|
actual, err := repo.FindByUsername("aDmIn")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(actual.Name).To(Equal("Admin"))
|
|
})
|
|
It("find the user by username and decrypts the password", func() {
|
|
actual, err := repo.FindByUsernameWithPassword("aDmIn")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(actual.Name).To(Equal("Admin"))
|
|
Expect(actual.Password).To(Equal("wordpass"))
|
|
})
|
|
It("updates the name and keep the same password", func() {
|
|
usr.Name = "Jane Doe"
|
|
usr.NewPassword = ""
|
|
Expect(repo.Put(&usr)).To(BeNil())
|
|
|
|
actual, err := repo.FindByUsernameWithPassword("admin")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(actual.Name).To(Equal("Jane Doe"))
|
|
Expect(actual.Password).To(Equal("wordpass"))
|
|
})
|
|
It("updates password if specified", func() {
|
|
usr.NewPassword = "newpass"
|
|
Expect(repo.Put(&usr)).To(BeNil())
|
|
|
|
actual, err := repo.FindByUsernameWithPassword("admin")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(actual.Password).To(Equal("newpass"))
|
|
})
|
|
It("persists and reads back the scrobble filter", func() {
|
|
usr := model.User{ID: "u-filter", UserName: "u-filter", Name: "Filter User",
|
|
ScrobbleFilter: `{"all":[{"contains":{"title":"????"}}]}`}
|
|
Expect(repo.Put(&usr)).To(Succeed())
|
|
|
|
saved, err := repo.Get("u-filter")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(saved.ScrobbleFilter).To(Equal(`{"all":[{"contains":{"title":"????"}}]}`))
|
|
})
|
|
It("reads back a user row inserted without scrobble_filter", func() {
|
|
// Guards the column's NOT NULL DEFAULT '': rows predating the migration must stay scannable
|
|
_, err := GetDBXBuilder().NewQuery(
|
|
"insert into user (id, user_name, name, email, password, created_at, updated_at) " +
|
|
"values ('u-rawsql', 'u-rawsql', 'Raw', '', '', datetime('now'), datetime('now'))").Execute()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
saved, err := repo.Get("u-rawsql")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(saved.ScrobbleFilter).To(Equal(""))
|
|
})
|
|
})
|
|
|
|
Describe("validatePasswordChange", func() {
|
|
var loggedUser *model.User
|
|
|
|
BeforeEach(func() {
|
|
loggedUser = &model.User{ID: "1", UserName: "logan"}
|
|
})
|
|
|
|
It("does nothing if passwords are not specified", func() {
|
|
user := &model.User{ID: "2", UserName: "johndoe"}
|
|
err := validatePasswordChange(user, loggedUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
Context("Autogenerated password (used with Reverse Proxy Authentication)", func() {
|
|
var user model.User
|
|
BeforeEach(func() {
|
|
loggedUser.IsAdmin = false
|
|
loggedUser.Password = consts.PasswordAutogenPrefix + id.NewRandom()
|
|
})
|
|
It("does nothing if passwords are not specified", func() {
|
|
user = *loggedUser
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
It("does not requires currentPassword for regular user", func() {
|
|
user = *loggedUser
|
|
user.CurrentPassword = ""
|
|
user.NewPassword = "new"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
It("does not requires currentPassword for admin", func() {
|
|
loggedUser.IsAdmin = true
|
|
user = *loggedUser
|
|
user.CurrentPassword = ""
|
|
user.NewPassword = "new"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
})
|
|
|
|
Context("Logged User is admin", func() {
|
|
BeforeEach(func() {
|
|
loggedUser.IsAdmin = true
|
|
})
|
|
It("can change other user's passwords without currentPassword", func() {
|
|
user := &model.User{ID: "2", UserName: "johndoe"}
|
|
user.NewPassword = "new"
|
|
err := validatePasswordChange(user, loggedUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
It("requires currentPassword to change its own", func() {
|
|
user := *loggedUser
|
|
user.NewPassword = "new"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
var verr *rest.ValidationError
|
|
errors.As(err, &verr)
|
|
Expect(verr.Errors).To(HaveLen(1))
|
|
Expect(verr.Errors).To(HaveKeyWithValue("currentPassword", "ra.validation.required"))
|
|
})
|
|
It("does not allow to change password to empty string", func() {
|
|
loggedUser.Password = "abc123"
|
|
user := *loggedUser
|
|
user.CurrentPassword = "abc123"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
var verr *rest.ValidationError
|
|
errors.As(err, &verr)
|
|
Expect(verr.Errors).To(HaveLen(1))
|
|
Expect(verr.Errors).To(HaveKeyWithValue("password", "ra.validation.required"))
|
|
})
|
|
It("fails if currentPassword does not match", func() {
|
|
loggedUser.Password = "abc123"
|
|
user := *loggedUser
|
|
user.CurrentPassword = "current"
|
|
user.NewPassword = "new"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
var verr *rest.ValidationError
|
|
errors.As(err, &verr)
|
|
Expect(verr.Errors).To(HaveLen(1))
|
|
Expect(verr.Errors).To(HaveKeyWithValue("currentPassword", "ra.validation.passwordDoesNotMatch"))
|
|
})
|
|
It("can change own password if requirements are met", func() {
|
|
loggedUser.Password = "abc123"
|
|
user := *loggedUser
|
|
user.CurrentPassword = "abc123"
|
|
user.NewPassword = "new"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
})
|
|
|
|
Context("Logged User is a regular user", func() {
|
|
BeforeEach(func() {
|
|
loggedUser.IsAdmin = false
|
|
})
|
|
It("requires currentPassword", func() {
|
|
user := *loggedUser
|
|
user.NewPassword = "new"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
var verr *rest.ValidationError
|
|
errors.As(err, &verr)
|
|
Expect(verr.Errors).To(HaveLen(1))
|
|
Expect(verr.Errors).To(HaveKeyWithValue("currentPassword", "ra.validation.required"))
|
|
})
|
|
It("does not allow to change password to empty string", func() {
|
|
loggedUser.Password = "abc123"
|
|
user := *loggedUser
|
|
user.CurrentPassword = "abc123"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
var verr *rest.ValidationError
|
|
errors.As(err, &verr)
|
|
Expect(verr.Errors).To(HaveLen(1))
|
|
Expect(verr.Errors).To(HaveKeyWithValue("password", "ra.validation.required"))
|
|
})
|
|
It("fails if currentPassword does not match", func() {
|
|
loggedUser.Password = "abc123"
|
|
user := *loggedUser
|
|
user.CurrentPassword = "current"
|
|
user.NewPassword = "new"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
var verr *rest.ValidationError
|
|
errors.As(err, &verr)
|
|
Expect(verr.Errors).To(HaveLen(1))
|
|
Expect(verr.Errors).To(HaveKeyWithValue("currentPassword", "ra.validation.passwordDoesNotMatch"))
|
|
})
|
|
It("can change own password if requirements are met", func() {
|
|
loggedUser.Password = "abc123"
|
|
user := *loggedUser
|
|
user.CurrentPassword = "abc123"
|
|
user.NewPassword = "new"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("Delete", func() {
|
|
It("returns not found for a missing user", func() {
|
|
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
|
|
adminRepo := NewUserRepository(adminCtx, GetDBXBuilder()).(*userRepository)
|
|
Expect(adminRepo.Delete("does-not-exist")).To(MatchError(model.ErrNotFound))
|
|
})
|
|
})
|
|
|
|
Describe("ReadAll name filter", func() {
|
|
var adminRepo model.ResourceRepository
|
|
|
|
BeforeEach(func() {
|
|
adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin-id", UserName: "admin", IsAdmin: true})
|
|
adminRepo = NewUserRepository(adminCtx, GetDBXBuilder()).(model.ResourceRepository)
|
|
|
|
for _, u := range []model.User{
|
|
{ID: "filter-alice", UserName: "alice_filter", Name: "Alice Filter", NewPassword: "x"},
|
|
{ID: "filter-bob", UserName: "bob_filter", Name: "Bob Filter", NewPassword: "x"},
|
|
} {
|
|
Expect(adminRepo.(model.UserRepository).Put(&u)).To(Succeed())
|
|
}
|
|
})
|
|
|
|
AfterEach(func() {
|
|
ur := adminRepo.(model.UserRepository)
|
|
_ = ur.Delete("filter-alice")
|
|
_ = ur.Delete("filter-bob")
|
|
})
|
|
|
|
It("matches users whose name starts with the given prefix", func() {
|
|
res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Alice"}})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
users := res.(model.Users)
|
|
|
|
var names []string
|
|
for _, u := range users {
|
|
names = append(names, u.Name)
|
|
}
|
|
Expect(names).To(ContainElement("Alice Filter"))
|
|
Expect(names).ToNot(ContainElement("Bob Filter"))
|
|
})
|
|
|
|
It("does not match names by mid-string substring (startsWith, not contains)", func() {
|
|
res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Filter"}})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
users := res.(model.Users)
|
|
|
|
for _, u := range users {
|
|
Expect(u.ID).ToNot(Or(Equal("filter-alice"), Equal("filter-bob")),
|
|
"a mid-string substring should not match a startsWith filter")
|
|
}
|
|
})
|
|
})
|
|
|
|
Describe("validateUsernameUnique", func() {
|
|
var repo *tests.MockedUserRepo
|
|
var existingUser *model.User
|
|
BeforeEach(func() {
|
|
existingUser = &model.User{ID: "1", UserName: "johndoe"}
|
|
repo = tests.CreateMockUserRepo()
|
|
err := repo.Put(existingUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
It("allows unique usernames", func() {
|
|
var newUser = &model.User{ID: "2", UserName: "unique_username"}
|
|
err := validateUsernameUnique(repo, newUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
It("returns ValidationError if username already exists", func() {
|
|
var newUser = &model.User{ID: "2", UserName: "johndoe"}
|
|
err := validateUsernameUnique(repo, newUser)
|
|
var verr *rest.ValidationError
|
|
isValidationError := errors.As(err, &verr)
|
|
|
|
Expect(isValidationError).To(BeTrue())
|
|
Expect(verr.Errors).To(HaveKeyWithValue("userName", "ra.validation.unique"))
|
|
})
|
|
It("returns generic error if repository call fails", func() {
|
|
repo.Error = errors.New("fake error")
|
|
|
|
var newUser = &model.User{ID: "2", UserName: "newuser"}
|
|
err := validateUsernameUnique(repo, newUser)
|
|
Expect(err).To(MatchError("fake error"))
|
|
})
|
|
})
|
|
|
|
Describe("Library Association Methods", func() {
|
|
var userID string
|
|
var library1, library2 model.Library
|
|
|
|
BeforeEach(func() {
|
|
// Create a test user first to satisfy foreign key constraints
|
|
testUser := model.User{
|
|
ID: "test-user-id",
|
|
UserName: "testuser",
|
|
Name: "Test User",
|
|
Email: "test@example.com",
|
|
NewPassword: "password",
|
|
IsAdmin: false,
|
|
}
|
|
Expect(repo.Put(&testUser)).To(BeNil())
|
|
userID = testUser.ID
|
|
|
|
library1 = model.Library{ID: 0, Name: "Library 500", Path: "/path/500"}
|
|
library2 = model.Library{ID: 0, Name: "Library 501", Path: "/path/501"}
|
|
|
|
// Create test libraries
|
|
libRepo := NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
|
|
Expect(libRepo.Put(&library1)).To(BeNil())
|
|
Expect(libRepo.Put(&library2)).To(BeNil())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
// Clean up user-library associations to ensure test isolation
|
|
_ = repo.SetUserLibraries(userID, []int{})
|
|
|
|
// Clean up test libraries to ensure isolation between test groups
|
|
libRepo := NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
|
|
_ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}})
|
|
})
|
|
|
|
Describe("GetUserLibraries", func() {
|
|
It("returns empty list when user has no library associations", func() {
|
|
libraries, err := repo.GetUserLibraries("non-existent-user")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(0))
|
|
})
|
|
|
|
It("returns user's associated libraries", func() {
|
|
err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
libraries, err := repo.GetUserLibraries(userID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(2))
|
|
|
|
libIDs := []int{libraries[0].ID, libraries[1].ID}
|
|
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
|
|
})
|
|
})
|
|
|
|
Describe("SetUserLibraries", func() {
|
|
It("sets user's library associations", func() {
|
|
libraryIDs := []int{library1.ID, library2.ID}
|
|
err := repo.SetUserLibraries(userID, libraryIDs)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
libraries, err := repo.GetUserLibraries(userID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(2))
|
|
})
|
|
|
|
It("replaces existing associations", func() {
|
|
// Set initial associations
|
|
err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Replace with just one library
|
|
err = repo.SetUserLibraries(userID, []int{library1.ID})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
libraries, err := repo.GetUserLibraries(userID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(1))
|
|
Expect(libraries[0].ID).To(Equal(library1.ID))
|
|
})
|
|
|
|
It("removes all associations when passed empty slice", func() {
|
|
// Set initial associations
|
|
err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Remove all
|
|
err = repo.SetUserLibraries(userID, []int{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
libraries, err := repo.GetUserLibraries(userID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(0))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("Admin User Auto-Assignment", func() {
|
|
var (
|
|
libRepo model.LibraryRepository
|
|
library1 model.Library
|
|
library2 model.Library
|
|
initialLibCount int
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
libRepo = NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
|
|
|
|
// Count initial libraries
|
|
existingLibs, err := libRepo.GetAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
initialLibCount = len(existingLibs)
|
|
|
|
library1 = model.Library{ID: 0, Name: "Admin Test Library 1", Path: "/admin/test/path1"}
|
|
library2 = model.Library{ID: 0, Name: "Admin Test Library 2", Path: "/admin/test/path2"}
|
|
|
|
// Create test libraries
|
|
Expect(libRepo.Put(&library1)).To(BeNil())
|
|
Expect(libRepo.Put(&library2)).To(BeNil())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
// Clean up test libraries and their associations
|
|
_ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}})
|
|
|
|
// Clean up user-library associations for these test libraries
|
|
_, _ = repo.(*userRepository).executeSQL(squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}}))
|
|
})
|
|
|
|
It("automatically assigns all libraries to admin users when created", func() {
|
|
adminUser := model.User{
|
|
ID: "admin-user-id-1",
|
|
UserName: "adminuser1",
|
|
Name: "Admin User",
|
|
Email: "admin1@example.com",
|
|
NewPassword: "password",
|
|
IsAdmin: true,
|
|
}
|
|
|
|
err := repo.Put(&adminUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Admin should automatically have access to all libraries (including existing ones)
|
|
libraries, err := repo.GetUserLibraries(adminUser.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries
|
|
|
|
libIDs := make([]int, len(libraries))
|
|
for i, lib := range libraries {
|
|
libIDs[i] = lib.ID
|
|
}
|
|
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
|
|
})
|
|
|
|
It("automatically assigns all libraries to admin users when updated", func() {
|
|
// Create regular user first
|
|
regularUser := model.User{
|
|
ID: "regular-user-id-1",
|
|
UserName: "regularuser1",
|
|
Name: "Regular User",
|
|
Email: "regular1@example.com",
|
|
NewPassword: "password",
|
|
IsAdmin: false,
|
|
}
|
|
|
|
err := repo.Put(®ularUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Give them access to just one library
|
|
err = repo.SetUserLibraries(regularUser.ID, []int{library1.ID})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Promote to admin
|
|
regularUser.IsAdmin = true
|
|
err = repo.Put(®ularUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Should now have access to all libraries (including existing ones)
|
|
libraries, err := repo.GetUserLibraries(regularUser.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries
|
|
|
|
libIDs := make([]int, len(libraries))
|
|
for i, lib := range libraries {
|
|
libIDs[i] = lib.ID
|
|
}
|
|
// Should include our test libraries plus all existing ones
|
|
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
|
|
})
|
|
|
|
It("assigns default libraries to regular users", func() {
|
|
regularUser := model.User{
|
|
ID: "regular-user-id-2",
|
|
UserName: "regularuser2",
|
|
Name: "Regular User",
|
|
Email: "regular2@example.com",
|
|
NewPassword: "password",
|
|
IsAdmin: false,
|
|
}
|
|
|
|
err := repo.Put(®ularUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Regular user should be assigned to default libraries (library ID 1 from migration)
|
|
libraries, err := repo.GetUserLibraries(regularUser.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(1))
|
|
Expect(libraries[0].ID).To(Equal(1))
|
|
Expect(libraries[0].DefaultNewUsers).To(BeTrue())
|
|
})
|
|
})
|
|
|
|
Describe("Libraries Field Population", func() {
|
|
var (
|
|
libRepo model.LibraryRepository
|
|
library1 model.Library
|
|
library2 model.Library
|
|
testUser model.User
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
libRepo = NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
|
|
library1 = model.Library{ID: 0, Name: "Field Test Library 1", Path: "/field/test/path1"}
|
|
library2 = model.Library{ID: 0, Name: "Field Test Library 2", Path: "/field/test/path2"}
|
|
|
|
// Create test libraries
|
|
Expect(libRepo.Put(&library1)).To(BeNil())
|
|
Expect(libRepo.Put(&library2)).To(BeNil())
|
|
|
|
// Create test user
|
|
testUser = model.User{
|
|
ID: "field-test-user",
|
|
UserName: "fieldtestuser",
|
|
Name: "Field Test User",
|
|
Email: "fieldtest@example.com",
|
|
NewPassword: "password",
|
|
IsAdmin: false,
|
|
}
|
|
Expect(repo.Put(&testUser)).To(BeNil())
|
|
|
|
// Assign libraries to user
|
|
Expect(repo.SetUserLibraries(testUser.ID, []int{library1.ID, library2.ID})).To(BeNil())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
// Clean up test libraries and their associations
|
|
_ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}})
|
|
_ = repo.(*userRepository).delete(squirrel.Eq{"id": testUser.ID})
|
|
|
|
// Clean up user-library associations for these test libraries
|
|
_, _ = repo.(*userRepository).executeSQL(squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}}))
|
|
})
|
|
|
|
It("populates Libraries field when getting a single user", func() {
|
|
user, err := repo.Get(testUser.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(user.Libraries).To(HaveLen(2))
|
|
|
|
libIDs := []int{user.Libraries[0].ID, user.Libraries[1].ID}
|
|
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
|
|
|
|
// Check that library details are properly populated
|
|
for _, lib := range user.Libraries {
|
|
switch lib.ID {
|
|
case library1.ID:
|
|
Expect(lib.Name).To(Equal("Field Test Library 1"))
|
|
Expect(lib.Path).To(Equal("/field/test/path1"))
|
|
case library2.ID:
|
|
Expect(lib.Name).To(Equal("Field Test Library 2"))
|
|
Expect(lib.Path).To(Equal("/field/test/path2"))
|
|
}
|
|
}
|
|
})
|
|
|
|
It("populates Libraries field when getting all users", func() {
|
|
users, err := repo.(*userRepository).GetAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Find our test user in the results
|
|
found := slices.IndexFunc(users, func(u model.User) bool { return u.ID == testUser.ID })
|
|
Expect(found).ToNot(Equal(-1))
|
|
|
|
foundUser := users[found]
|
|
Expect(foundUser).ToNot(BeNil())
|
|
Expect(foundUser.Libraries).To(HaveLen(2))
|
|
|
|
libIDs := []int{foundUser.Libraries[0].ID, foundUser.Libraries[1].ID}
|
|
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
|
|
})
|
|
|
|
It("populates Libraries field when finding user by username", func() {
|
|
user, err := repo.FindByUsername(testUser.UserName)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(user.Libraries).To(HaveLen(2))
|
|
|
|
libIDs := []int{user.Libraries[0].ID, user.Libraries[1].ID}
|
|
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
|
|
})
|
|
|
|
It("returns default Libraries array for new regular users", func() {
|
|
// Create a user with no explicit library associations - should get default libraries
|
|
userWithoutLibs := model.User{
|
|
ID: "no-libs-user",
|
|
UserName: "nolibsuser",
|
|
Name: "No Libs User",
|
|
Email: "nolibs@example.com",
|
|
NewPassword: "password",
|
|
IsAdmin: false,
|
|
}
|
|
Expect(repo.Put(&userWithoutLibs)).To(BeNil())
|
|
defer func() { _ = repo.(*userRepository).delete(squirrel.Eq{"id": userWithoutLibs.ID}) }()
|
|
|
|
user, err := repo.Get(userWithoutLibs.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(user.Libraries).ToNot(BeNil())
|
|
// Regular users should be assigned to default libraries (library ID 1 from migration)
|
|
Expect(user.Libraries).To(HaveLen(1))
|
|
Expect(user.Libraries[0].ID).To(Equal(1))
|
|
})
|
|
})
|
|
|
|
Describe("validateScrobbleFilter", func() {
|
|
It("accepts an empty filter", func() {
|
|
u := &model.User{}
|
|
Expect(validateScrobbleFilter(u)).To(Succeed())
|
|
})
|
|
It("trims a whitespace-only filter to empty", func() {
|
|
u := &model.User{ScrobbleFilter: " "}
|
|
Expect(validateScrobbleFilter(u)).To(Succeed())
|
|
Expect(u.ScrobbleFilter).To(Equal(""))
|
|
})
|
|
It("accepts valid criteria JSON", func() {
|
|
u := &model.User{ScrobbleFilter: `{"all":[{"lt":{"rating":4}}]}`}
|
|
Expect(validateScrobbleFilter(u)).To(Succeed())
|
|
})
|
|
It("rejects malformed JSON", func() {
|
|
u := &model.User{ScrobbleFilter: `{not json`}
|
|
var vErr *rest.ValidationError
|
|
err := validateScrobbleFilter(u)
|
|
Expect(errors.As(err, &vErr)).To(BeTrue())
|
|
Expect(vErr.Errors).To(HaveKey("scrobbleFilter"))
|
|
})
|
|
It("rejects criteria without rules", func() {
|
|
u := &model.User{ScrobbleFilter: `{"sort":"title"}`}
|
|
Expect(validateScrobbleFilter(u)).ToNot(Succeed())
|
|
})
|
|
It("rejects selection options that mean nothing for a single track", func() {
|
|
for _, f := range []string{
|
|
`{"all":[{"lt":{"rating":4}}],"limit":100}`,
|
|
`{"all":[{"lt":{"rating":4}}],"limitPercent":10}`,
|
|
`{"all":[{"lt":{"rating":4}}],"offset":5}`,
|
|
`{"all":[{"lt":{"rating":4}}],"refreshDelay":"1h"}`,
|
|
} {
|
|
u := &model.User{ScrobbleFilter: f}
|
|
Expect(validateScrobbleFilter(u)).ToNot(Succeed(), f)
|
|
}
|
|
})
|
|
It("accepts a sort, which cannot change a single-track match", func() {
|
|
u := &model.User{ScrobbleFilter: `{"all":[{"lt":{"rating":4}}],"sort":"title"}`}
|
|
Expect(validateScrobbleFilter(u)).To(Succeed())
|
|
})
|
|
It("rejects unknown fields", func() {
|
|
u := &model.User{ScrobbleFilter: `{"all":[{"is":{"bogusfield":1}}]}`}
|
|
Expect(validateScrobbleFilter(u)).ToNot(Succeed())
|
|
})
|
|
})
|
|
|
|
Describe("filters", func() {
|
|
It("qualifies id filter with table name", func() {
|
|
r := repo.(*userRepository)
|
|
qo := r.parseRestOptions(r.ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}})
|
|
sel := r.selectUserWithLibraries(qo)
|
|
query, _, err := r.toSQL(sel)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(query).To(ContainSubstring("user.id = {:p0}"))
|
|
})
|
|
})
|
|
|
|
Describe("token epoch", func() {
|
|
var repo model.UserRepository
|
|
var usr model.User
|
|
|
|
newUser := func() model.User {
|
|
uid := id.NewRandom()
|
|
// user_name is unique; suffix it so each It gets its own row in the shared suite DB.
|
|
return model.User{ID: uid, UserName: "epoch-user-" + uid, Name: "Epoch", NewPassword: "hunter2"}
|
|
}
|
|
|
|
BeforeEach(func() {
|
|
ctx := log.NewContext(context.TODO())
|
|
ctx = request.WithUser(ctx, model.User{ID: "userid", IsAdmin: true})
|
|
repo = NewUserRepository(ctx, GetDBXBuilder())
|
|
usr = newUser()
|
|
Expect(repo.Put(&usr)).To(Succeed())
|
|
})
|
|
|
|
It("starts at zero for a new user", func() {
|
|
got, err := repo.Get(usr.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(got.TokenEpoch).To(Equal(0))
|
|
})
|
|
|
|
It("increments once per password change", func() {
|
|
usr.NewPassword = "second"
|
|
Expect(repo.Put(&usr)).To(Succeed())
|
|
got, err := repo.Get(usr.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(got.TokenEpoch).To(Equal(1))
|
|
|
|
usr.NewPassword = "third"
|
|
Expect(repo.Put(&usr)).To(Succeed())
|
|
got, err = repo.Get(usr.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(got.TokenEpoch).To(Equal(2))
|
|
})
|
|
|
|
It("leaves the epoch alone when the password is untouched", func() {
|
|
usr.NewPassword = ""
|
|
usr.Name = "Renamed"
|
|
Expect(repo.Put(&usr)).To(Succeed())
|
|
|
|
got, err := repo.Get(usr.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(got.TokenEpoch).To(Equal(0))
|
|
Expect(got.Name).To(Equal("Renamed"))
|
|
})
|
|
|
|
It("never signals the same epoch to two concurrent password changes", func() {
|
|
// Each writer's epoch must be the one its own UPDATE produced.
|
|
const callers = 4
|
|
var mu sync.Mutex
|
|
var signalled []int
|
|
var wg sync.WaitGroup
|
|
for range callers {
|
|
wg.Go(func() {
|
|
ctx := log.NewContext(context.TODO())
|
|
ctx = request.WithUser(ctx, model.User{ID: usr.ID})
|
|
ctx = request.WithTokenEpochHolder(ctx)
|
|
own := NewUserRepository(ctx, GetDBXBuilder())
|
|
|
|
u := usr
|
|
u.NewPassword = "concurrent"
|
|
if err := own.Put(&u); err != nil {
|
|
return // the shared in-memory test DB can raise SQLITE_LOCKED
|
|
}
|
|
epoch, ok := request.TokenEpochFrom(ctx)
|
|
if !ok {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
signalled = append(signalled, epoch)
|
|
})
|
|
}
|
|
wg.Wait()
|
|
|
|
Expect(signalled).To(HaveLen(len(slice.Unique(signalled))),
|
|
"an epoch was signalled to more than one writer: %v", signalled)
|
|
})
|
|
})
|
|
|
|
Describe("Put and the token epoch", func() {
|
|
newRepo := func(actingUserID string) model.UserRepository {
|
|
ctx := log.NewContext(context.TODO())
|
|
ctx = request.WithUser(ctx, model.User{ID: actingUserID, IsAdmin: true})
|
|
ctx = request.WithTokenEpochHolder(ctx)
|
|
return NewUserRepository(ctx, GetDBXBuilder())
|
|
}
|
|
|
|
It("does not bump when creating a user", func() {
|
|
repo := newRepo("admin")
|
|
usr := model.User{ID: id.NewRandom(), UserName: "fresh", NewPassword: "pw1"}
|
|
Expect(repo.Put(&usr)).To(Succeed())
|
|
|
|
got, err := repo.Get(usr.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(got.TokenEpoch).To(Equal(0))
|
|
})
|
|
|
|
It("bumps when the password changes", func() {
|
|
repo := newRepo("admin")
|
|
usr := model.User{ID: id.NewRandom(), UserName: "changer", NewPassword: "pw1"}
|
|
Expect(repo.Put(&usr)).To(Succeed())
|
|
|
|
usr.NewPassword = "pw2"
|
|
Expect(repo.Put(&usr)).To(Succeed())
|
|
|
|
got, err := repo.Get(usr.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(got.TokenEpoch).To(Equal(1))
|
|
})
|
|
|
|
It("does not bump on an edit that leaves the password alone", func() {
|
|
repo := newRepo("admin")
|
|
usr := model.User{ID: id.NewRandom(), UserName: "renamer", NewPassword: "pw1"}
|
|
Expect(repo.Put(&usr)).To(Succeed())
|
|
|
|
usr.NewPassword = ""
|
|
usr.Name = "New Display Name"
|
|
Expect(repo.Put(&usr)).To(Succeed())
|
|
|
|
got, err := repo.Get(usr.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(got.TokenEpoch).To(Equal(0))
|
|
})
|
|
|
|
It("signals the new epoch when a user changes their own password", func() {
|
|
userID := id.NewRandom()
|
|
repo := newRepo(userID)
|
|
usr := model.User{ID: userID, UserName: "self", NewPassword: "pw1"}
|
|
Expect(repo.Put(&usr)).To(Succeed())
|
|
|
|
usr.NewPassword = "pw2"
|
|
Expect(repo.Put(&usr)).To(Succeed())
|
|
|
|
epoch, ok := request.TokenEpochFrom(repo.(*userRepository).ctx)
|
|
Expect(ok).To(BeTrue())
|
|
Expect(epoch).To(Equal(1))
|
|
})
|
|
|
|
It("does not signal when an admin changes someone else's password", func() {
|
|
repo := newRepo("some-admin")
|
|
usr := model.User{ID: id.NewRandom(), UserName: "other", NewPassword: "pw1"}
|
|
Expect(repo.Put(&usr)).To(Succeed())
|
|
|
|
usr.NewPassword = "pw2"
|
|
Expect(repo.Put(&usr)).To(Succeed())
|
|
|
|
_, ok := request.TokenEpochFrom(repo.(*userRepository).ctx)
|
|
Expect(ok).To(BeFalse())
|
|
})
|
|
})
|
|
})
|