mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* feat(auth): add per-user token_epoch column and bump method * feat(auth): add aud and ep claims, omitted when zero * feat(auth): add CreateAPIToken for non-expiring, audience-scoped tokens * feat(auth): add CheckClaims for epoch and audience validation * feat(jellyfin): issue non-expiring, jellyfin-scoped access tokens * fix(subsonic): reject API-scoped and revoked tokens on the jwt path * fix(server): reject API-scoped and revoked tokens on the native API * fix(server): pin the token-subject guard and stop leaking test config Adds a regression spec for the DevAutoLogin/ExtAuth guard in tokenAllowed, switches its comparison to case-insensitive to match the user lookup's own COLLATE NOCASE semantics, and restores Subsonic JWT test config after each spec instead of leaking SessionTimeout. * feat(request): add a token epoch holder for handler-to-middleware signalling * refactor(server): write the refreshed JWT header after the handler runs * feat(auth): revoke all tokens for a user when their password changes * fix(server): restore Unwrap on the JWT refresh writer so SSE write deadlines apply * test(auth): pin that non-session tokens reject API access tokens * test(jellyfin): pin token scoping and epoch revocation end to end Exercises auth.CreateAPIToken and CheckClaims against the real Jellyfin router and SQLite DB: the minted token has no exp and is aud-scoped to jellyfin, and bumping token_epoch through the real UserRepository revokes an already-issued token on the next protected request. * test(nativeapi): pin the token-epoch handoff through a real password-change request Drive a self password change through the real Authenticator/JWTRefresher chain and a real SQLite-backed userRepository, so the epoch handoff between Put and the refreshed-token writer is verified end to end, not as two separately-tested halves. Also fix tokenAllowed to read the enriched ctx it was given instead of r.Context(), so its warning log carries the username. * refactor(server): drop tokenAllowed's now-unused request parameter Finding-2 already moved every use to ctx; r was dead weight. Also note in the new nativeapi test why it must stay the package's only real-DB spec: db.Db() is a process-wide singleton its cleanup closes for good. * refactor(auth): remove duplication in claim decoding and token minting * refactor(auth): group aud with the standard JWT claims * refactor(auth): read aud with the standard-claim accessor pattern * fix(log): redact every api_key spelling the Jellyfin API accepts * fix(auth): bind session tokens to the user id, not just the username * fix(auth): return the token epoch from the same atomic increment * fix(auth): bump the token epoch in the same statement as the password write * chore(auth): trim comments to the why-only budget
844 lines
28 KiB
Go
844 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("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())
|
|
})
|
|
})
|
|
})
|