navidrome/persistence/user_repository_test.go
Deluan Quintão b293b96256
refactor(persistence): stateless repositories with per-call context (#6149)
* refactor(persistence): adopt generic deluan/rest repository API

Pin deluan/rest to the refactor branch. REST-facing repository methods
take a context and return typed values. Drop DataStore.Resource and
ResourceRepository; the native API names typed repositories directly
through a per-request adapter that later commits remove.

* refactor(persistence): base repository helpers take a context

* refactor(persistence): LibraryRepository takes a context per call

* refactor(persistence): PropertyRepository takes a context per call

* refactor(persistence): UserPropsRepository takes a context per call

* refactor(persistence): TranscodingRepository takes a context per call

* refactor(persistence): ShareRepository takes a context per call

* refactor(persistence): PlayerRepository takes a context per call

* refactor(persistence): RadioRepository takes a context per call

* refactor(persistence): PlayQueueRepository takes a context per call

* refactor(persistence): Tag and Genre repositories take a context per call

* refactor(persistence): PluginRepository takes a context per call

* refactor(persistence): Scrobble repositories take a context per call

* refactor(persistence): FolderRepository takes a context per call

* refactor(persistence): Artwork repositories take a context per call

* refactor(persistence): UserRepository takes a context per call

* refactor(persistence): ArtistRepository takes a context per call

ReadAll no longer rewrites the shared sort mappings for the role filter;
it works on a per-call copy.

* test(persistence): assert artist role sort sanitization in ReadAll

* refactor(persistence): AlbumRepository takes a context per call

* test(persistence): pass the test context to album repository helpers

* refactor(persistence): MediaFileRepository takes a context per call

* refactor(persistence): Playlist repositories take a context per call

* refactor(persistence): build all repositories once per store

* refactor(core): REST repository wrappers are built once

* refactor(persistence): repositories are stateless

Remove the context field from the base repository and the per-request
REST adapter. Enable the containedctx linter so no repository can hold a
request context again.

* chore(lint): skip containedctx in test files

* refactor: share simplifications from the stateless repositories sweep

Add deleteOwnedAll on sqlRepository and use it in player/share Delete
to remove the duplicated bulk-delete loop; have Share.Repository()
return model.ShareRepository so subsonic sharing.go drops its repeated
type assertions.

* chore(core): assert REST wrappers implement Persistable

* chore: reformat imports

* perf(persistence): build repositories on first use

Each transaction store used to construct all 21 repositories up front,
paying for filter and sort mapping setup the block never touched. Fields
are now sync.OnceValue thunks, so a store only builds what it uses.

* fix(persistence): clean plugin references per deleted user

A bulk user delete that fails on a later id had already removed the
earlier rows but skipped their plugin cleanup. Cleanup now runs right
after each successful delete.

* fix(core): unload disabled plugins even when a user delete fails

A bulk delete can fail on a later id after earlier users were removed
and their plugins auto-disabled. The wrapper returned before unloading,
leaving those plugins running until the next successful delete or a
restart.

* chore(deps): pin deluan/rest to v1.0.1

Replaces the pseudo-version of the refactor branch with the tagged
release. REST error messages now name the bare type (Artist, not
model.Artist).

* test: use the spec context instead of context.Background()

Replace the context.Background()/context.TODO() calls this branch added
to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so
repository calls are bound to the running spec's lifetime.

* test: declare the spec context once per Describe

Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
2026-09-25 18:06:10 -04:00

849 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
var ctx context.Context
BeforeEach(func() {
ctx = log.NewContext(GinkgoT().Context())
repo = NewUserRepository(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(ctx, &usr)).To(BeNil())
})
It("returns the newly created user", func() {
actual, err := repo.Get(ctx, "123")
Expect(err).ToNot(HaveOccurred())
Expect(actual.Name).To(Equal("Admin"))
})
It("find the user by case-insensitive username", func() {
actual, err := repo.FindByUsername(ctx, "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(ctx, "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(ctx, &usr)).To(BeNil())
actual, err := repo.FindByUsernameWithPassword(ctx, "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(ctx, &usr)).To(BeNil())
actual, err := repo.FindByUsernameWithPassword(ctx, "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(ctx, &usr)).To(Succeed())
saved, err := repo.Get(ctx, "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(ctx, "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(GetDBXBuilder()).(*userRepository)
Expect(adminRepo.Delete(adminCtx, "does-not-exist")).To(MatchError(model.ErrNotFound))
})
})
Describe("ReadAll name filter", func() {
var adminRepo model.UserRepository
var adminCtx context.Context
BeforeEach(func() {
adminCtx = request.WithUser(ctx, model.User{ID: "admin-id", UserName: "admin", IsAdmin: true})
adminRepo = NewUserRepository(GetDBXBuilder())
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.Put(adminCtx, &u)).To(Succeed())
}
})
AfterEach(func() {
_ = adminRepo.Delete(adminCtx, "filter-alice", "filter-bob")
})
It("matches users whose name starts with the given prefix", func() {
users, err := adminRepo.ReadAll(adminCtx, rest.QueryOptions{Filters: map[string]any{"name": "Alice"}})
Expect(err).ToNot(HaveOccurred())
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() {
users, err := adminRepo.ReadAll(adminCtx, rest.QueryOptions{Filters: map[string]any{"name": "Filter"}})
Expect(err).ToNot(HaveOccurred())
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(ctx, existingUser)
Expect(err).ToNot(HaveOccurred())
})
It("allows unique usernames", func() {
var newUser = &model.User{ID: "2", UserName: "unique_username"}
err := validateUsernameUnique(ctx, repo, newUser)
Expect(err).ToNot(HaveOccurred())
})
It("returns ValidationError if username already exists", func() {
var newUser = &model.User{ID: "2", UserName: "johndoe"}
err := validateUsernameUnique(ctx, 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(ctx, 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(ctx, &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(GetDBXBuilder())
Expect(libRepo.Put(ctx, &library1)).To(BeNil())
Expect(libRepo.Put(ctx, &library2)).To(BeNil())
})
AfterEach(func() {
// Clean up user-library associations to ensure test isolation
_ = repo.SetUserLibraries(ctx, userID, []int{})
// Clean up test libraries to ensure isolation between test groups
libRepo := NewLibraryRepository(GetDBXBuilder())
_ = libRepo.(*libraryRepository).delete(ctx, 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(ctx, "non-existent-user")
Expect(err).ToNot(HaveOccurred())
Expect(libraries).To(HaveLen(0))
})
It("returns user's associated libraries", func() {
err := repo.SetUserLibraries(ctx, userID, []int{library1.ID, library2.ID})
Expect(err).ToNot(HaveOccurred())
libraries, err := repo.GetUserLibraries(ctx, 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(ctx, userID, libraryIDs)
Expect(err).ToNot(HaveOccurred())
libraries, err := repo.GetUserLibraries(ctx, userID)
Expect(err).ToNot(HaveOccurred())
Expect(libraries).To(HaveLen(2))
})
It("replaces existing associations", func() {
// Set initial associations
err := repo.SetUserLibraries(ctx, userID, []int{library1.ID, library2.ID})
Expect(err).ToNot(HaveOccurred())
// Replace with just one library
err = repo.SetUserLibraries(ctx, userID, []int{library1.ID})
Expect(err).ToNot(HaveOccurred())
libraries, err := repo.GetUserLibraries(ctx, 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(ctx, userID, []int{library1.ID, library2.ID})
Expect(err).ToNot(HaveOccurred())
// Remove all
err = repo.SetUserLibraries(ctx, userID, []int{})
Expect(err).ToNot(HaveOccurred())
libraries, err := repo.GetUserLibraries(ctx, 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(GetDBXBuilder())
// Count initial libraries
existingLibs, err := libRepo.GetAll(ctx)
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(ctx, &library1)).To(BeNil())
Expect(libRepo.Put(ctx, &library2)).To(BeNil())
})
AfterEach(func() {
// Clean up test libraries and their associations
_ = libRepo.(*libraryRepository).delete(ctx, squirrel.Eq{"id": []int{library1.ID, library2.ID}})
// Clean up user-library associations for these test libraries
_, _ = repo.(*userRepository).executeSQL(ctx, 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(ctx, &adminUser)
Expect(err).ToNot(HaveOccurred())
// Admin should automatically have access to all libraries (including existing ones)
libraries, err := repo.GetUserLibraries(ctx, 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(ctx, &regularUser)
Expect(err).ToNot(HaveOccurred())
// Give them access to just one library
err = repo.SetUserLibraries(ctx, regularUser.ID, []int{library1.ID})
Expect(err).ToNot(HaveOccurred())
// Promote to admin
regularUser.IsAdmin = true
err = repo.Put(ctx, &regularUser)
Expect(err).ToNot(HaveOccurred())
// Should now have access to all libraries (including existing ones)
libraries, err := repo.GetUserLibraries(ctx, 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(ctx, &regularUser)
Expect(err).ToNot(HaveOccurred())
// Regular user should be assigned to default libraries (library ID 1 from migration)
libraries, err := repo.GetUserLibraries(ctx, 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(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(ctx, &library1)).To(BeNil())
Expect(libRepo.Put(ctx, &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(ctx, &testUser)).To(BeNil())
// Assign libraries to user
Expect(repo.SetUserLibraries(ctx, testUser.ID, []int{library1.ID, library2.ID})).To(BeNil())
})
AfterEach(func() {
// Clean up test libraries and their associations
_ = libRepo.(*libraryRepository).delete(ctx, squirrel.Eq{"id": []int{library1.ID, library2.ID}})
_ = repo.(*userRepository).delete(ctx, squirrel.Eq{"id": testUser.ID})
// Clean up user-library associations for these test libraries
_, _ = repo.(*userRepository).executeSQL(ctx, 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(ctx, 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(ctx)
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(ctx, 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(ctx, &userWithoutLibs)).To(BeNil())
defer func() { _ = repo.(*userRepository).delete(ctx, squirrel.Eq{"id": userWithoutLibs.ID}) }()
user, err := repo.Get(ctx, 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(ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}})
sel := r.selectUserWithLibraries(ctx, 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 = request.WithUser(ctx, model.User{ID: "userid", IsAdmin: true})
repo = NewUserRepository(GetDBXBuilder())
usr = newUser()
Expect(repo.Put(ctx, &usr)).To(Succeed())
})
It("starts at zero for a new user", func() {
got, err := repo.Get(ctx, 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(ctx, &usr)).To(Succeed())
got, err := repo.Get(ctx, usr.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.TokenEpoch).To(Equal(1))
usr.NewPassword = "third"
Expect(repo.Put(ctx, &usr)).To(Succeed())
got, err = repo.Get(ctx, 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(ctx, &usr)).To(Succeed())
got, err := repo.Get(ctx, 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(GetDBXBuilder())
u := usr
u.NewPassword = "concurrent"
if err := own.Put(ctx, &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) (context.Context, model.UserRepository) {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: actingUserID, IsAdmin: true})
ctx = request.WithTokenEpochHolder(ctx)
return ctx, NewUserRepository(GetDBXBuilder())
}
It("does not bump when creating a user", func() {
ctx, repo := newRepo("admin")
usr := model.User{ID: id.NewRandom(), UserName: "fresh", NewPassword: "pw1"}
Expect(repo.Put(ctx, &usr)).To(Succeed())
got, err := repo.Get(ctx, usr.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.TokenEpoch).To(Equal(0))
})
It("bumps when the password changes", func() {
ctx, repo := newRepo("admin")
usr := model.User{ID: id.NewRandom(), UserName: "changer", NewPassword: "pw1"}
Expect(repo.Put(ctx, &usr)).To(Succeed())
usr.NewPassword = "pw2"
Expect(repo.Put(ctx, &usr)).To(Succeed())
got, err := repo.Get(ctx, 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() {
ctx, repo := newRepo("admin")
usr := model.User{ID: id.NewRandom(), UserName: "renamer", NewPassword: "pw1"}
Expect(repo.Put(ctx, &usr)).To(Succeed())
usr.NewPassword = ""
usr.Name = "New Display Name"
Expect(repo.Put(ctx, &usr)).To(Succeed())
got, err := repo.Get(ctx, 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()
ctx, repo := newRepo(userID)
usr := model.User{ID: userID, UserName: "self", NewPassword: "pw1"}
Expect(repo.Put(ctx, &usr)).To(Succeed())
usr.NewPassword = "pw2"
Expect(repo.Put(ctx, &usr)).To(Succeed())
epoch, ok := request.TokenEpochFrom(ctx)
Expect(ok).To(BeTrue())
Expect(epoch).To(Equal(1))
})
It("does not signal when an admin changes someone else's password", func() {
ctx, repo := newRepo("some-admin")
usr := model.User{ID: id.NewRandom(), UserName: "other", NewPassword: "pw1"}
Expect(repo.Put(ctx, &usr)).To(Succeed())
usr.NewPassword = "pw2"
Expect(repo.Put(ctx, &usr)).To(Succeed())
_, ok := request.TokenEpochFrom(ctx)
Expect(ok).To(BeFalse())
})
})
})