navidrome/persistence/user_repository_test.go
Deluan 1b5c68a203 refactor(log): mark secrets on their own line and cover session keys
Mark secret values with log.WithSecrets on a separate line instead of
nesting the call in argument lists. Also mark Last.fm/ListenBrainz session
keys written through SessionKeys.Put and the PasswordEncryptionKey checksum,
which still reached trace logs, and ignore values shorter than 8 characters
so a short plaintext marked after a failed encryption cannot mangle SQL text
or the [REDACTED] marker.
2026-09-28 20:06:28 -04:00

903 lines
31 KiB
Go

package persistence
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"slices"
"sync"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"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"
"github.com/pocketbase/dbx"
)
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("never logs the stored password, on insert or update, but still logs the user name", func() {
logs := captureTraceLogs()
storedPassword := func(id string) string {
var enc string
Expect(GetDBXBuilder().NewQuery("select password from user where id = {:id}").
Bind(dbx.Params{"id": id}).Row(&enc)).To(Succeed())
return enc
}
u := model.User{ID: "u-logged", UserName: "logged-user-name", NewPassword: "first-secret"}
Expect(repo.Put(ctx, &u)).To(Succeed())
inserted := storedPassword(u.ID)
u.NewPassword = "second-secret"
Expect(repo.Put(ctx, &u)).To(Succeed())
updated := storedPassword(u.ID)
Expect(logs.String()).To(ContainSubstring("INSERT INTO user"))
Expect(logs.String()).To(ContainSubstring("UPDATE user"))
Expect(logs.String()).To(ContainSubstring("logged-user-name"))
for _, secret := range []string{inserted, updated, "first-secret", "second-secret"} {
Expect(logs.String()).ToNot(ContainSubstring(secret))
}
})
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("initPasswordEncryptionKey", func() {
It("never logs the encryption key checksum, but still logs its property id", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.PasswordEncryptionKey = "a-new-password-encryption-key"
keySum := fmt.Sprintf("%x", sha256.Sum256(keyTo32Bytes(conf.Server.PasswordEncryptionKey)))
previousKey := encKey
DeferCleanup(func() { encKey = previousKey })
tx, err := GetDBXBuilder().Begin()
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() { _ = tx.Rollback() })
_, err = tx.NewQuery("delete from user").Execute()
Expect(err).ToNot(HaveOccurred())
txRepo := NewUserRepository(tx).(*userRepository)
Expect(txRepo.Put(ctx, &model.User{ID: "u-rekey", UserName: "rekeyed-user", NewPassword: "rekeyed-password"})).To(Succeed())
logs := captureTraceLogs()
Expect(txRepo.initPasswordEncryptionKey(ctx)).To(Succeed())
Expect(logs.String()).To(ContainSubstring("UPDATE user"))
Expect(logs.String()).To(ContainSubstring(consts.PasswordsEncryptedKey))
Expect(logs.String()).ToNot(ContainSubstring(keySum))
var rekeyed string
Expect(tx.NewQuery("select password from user where id = 'u-rekey'").Row(&rekeyed)).To(Succeed())
Expect(logs.String()).ToNot(ContainSubstring(rekeyed))
})
})
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())
})
})
})