feat(database): add user_library table and library access methods

Signed-off-by: Deluan <deluan@navidrome.org>

# Conflicts:
#	tests/mock_library_repo.go
This commit is contained in:
Deluan 2025-06-06 13:20:37 -04:00
commit 40bbae16c4
9 changed files with 533 additions and 6 deletions

View file

@ -2,6 +2,7 @@ package persistence
import (
"context"
"fmt"
"sync"
"time"
@ -87,6 +88,17 @@ func (r *libraryRepository) Put(l *model.Library) error {
defer libLock.Unlock()
libCache[l.ID] = l.Path
}
// Auto-assign library to all admin users
if err == nil {
sql := Expr(
"INSERT INTO user_library (user_id, library_id) SELECT id, ? FROM user WHERE is_admin = true ON CONFLICT (user_id, library_id) DO NOTHING",
l.ID,
)
if _, addErr := r.executeSQL(sql); addErr != nil {
return fmt.Errorf("failed to assign library to admin users: %w", addErr)
}
}
return err
}
@ -206,4 +218,18 @@ func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries,
return res, err
}
// User-library association methods
func (r *libraryRepository) GetUsersWithLibraryAccess(libraryID int) (model.Users, error) {
sel := Select("u.*").
From("user u").
Join("user_library ul ON u.id = ul.user_id").
Where(Eq{"ul.library_id": libraryID}).
OrderBy("u.name")
var res model.Users
err := r.queryAll(sel, &res)
return res, err
}
var _ model.LibraryRepository = (*libraryRepository)(nil)

View file

@ -79,18 +79,34 @@ func (r *userRepository) Put(u *model.User) error {
return fmt.Errorf("error converting user to SQL args: %w", err)
}
delete(values, "current_password")
// Save/update the user
update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values)
count, err := r.executeSQL(update)
if err != nil {
return err
}
if count > 0 {
return nil
if count == 0 {
values["created_at"] = time.Now()
insert := Insert(r.tableName).SetMap(values)
_, err = r.executeSQL(insert)
if err != nil {
return err
}
}
values["created_at"] = time.Now()
insert := Insert(r.tableName).SetMap(values)
_, err = r.executeSQL(insert)
return err
// Auto-assign all libraries to admin users in a single SQL operation
if u.IsAdmin {
sql := Expr(
"INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library",
u.ID,
)
if _, err := r.executeSQL(sql); err != nil {
return fmt.Errorf("failed to assign all libraries to admin user: %w", err)
}
}
return nil
}
func (r *userRepository) FindFirstAdmin() (*model.User, error) {
@ -365,6 +381,57 @@ func (r *userRepository) decryptAllPasswords(users model.Users) error {
return nil
}
// Library association methods
func (r *userRepository) GetUserLibraries(userID string) (model.Libraries, error) {
sel := Select("l.*").
From("library l").
Join("user_library ul ON l.id = ul.library_id").
Where(Eq{"ul.user_id": userID}).
OrderBy("l.name")
var res model.Libraries
err := r.queryAll(sel, &res)
return res, err
}
func (r *userRepository) SetUserLibraries(userID string, libraryIDs []int) error {
// Remove existing associations
delSql := Delete("user_library").Where(Eq{"user_id": userID})
if _, err := r.executeSQL(delSql); err != nil {
return err
}
// Add new associations
if len(libraryIDs) > 0 {
insert := Insert("user_library").Columns("user_id", "library_id")
for _, libID := range libraryIDs {
insert = insert.Values(userID, libID)
}
_, err := r.executeSQL(insert)
return err
}
return nil
}
func (r *userRepository) AddUserLibrary(userID string, libraryID int) error {
insert := Insert("user_library").
Columns("user_id", "library_id").
Values(userID, libraryID).
Suffix("ON CONFLICT (user_id, library_id) DO NOTHING")
_, err := r.executeSQL(insert)
return err
}
func (r *userRepository) RemoveUserLibrary(userID string, libraryID int) error {
del := Delete("user_library").Where(And{
Eq{"user_id": userID},
Eq{"library_id": libraryID},
})
_, err := r.executeSQL(del)
return err
}
var _ model.UserRepository = (*userRepository)(nil)
var _ rest.Repository = (*userRepository)(nil)
var _ rest.Persistable = (*userRepository)(nil)

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
@ -235,4 +236,260 @@ var _ = Describe("UserRepository", func() {
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: 500, Name: "Library 500", Path: "/path/500"}
library2 = model.Library{ID: 501, 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{500, 501}})
})
Describe("AddUserLibrary", func() {
It("adds a library association for a user", func() {
err := repo.AddUserLibrary(userID, library1.ID)
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(1))
Expect(libraries[0].ID).To(Equal(library1.ID))
})
It("does not create duplicate associations", func() {
err := repo.AddUserLibrary(userID, library1.ID)
Expect(err).To(BeNil())
// Add the same association again
err = repo.AddUserLibrary(userID, library1.ID)
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(1))
})
})
Describe("GetUserLibraries", func() {
It("returns empty list when user has no library associations", func() {
libraries, err := repo.GetUserLibraries("non-existent-user")
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(0))
})
It("returns user's associated libraries", func() {
err := repo.AddUserLibrary(userID, library1.ID)
Expect(err).To(BeNil())
err = repo.AddUserLibrary(userID, library2.ID)
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
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).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(2))
})
It("replaces existing associations", func() {
// Set initial associations
err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID})
Expect(err).To(BeNil())
// Replace with just one library
err = repo.SetUserLibraries(userID, []int{library1.ID})
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
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).To(BeNil())
// Remove all
err = repo.SetUserLibraries(userID, []int{})
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(0))
})
})
Describe("RemoveUserLibrary", func() {
It("removes a library association", func() {
err := repo.AddUserLibrary(userID, library1.ID)
Expect(err).To(BeNil())
err = repo.AddUserLibrary(userID, library2.ID)
Expect(err).To(BeNil())
err = repo.RemoveUserLibrary(userID, library1.ID)
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(1))
Expect(libraries[0].ID).To(Equal(library2.ID))
})
It("does nothing when association doesn't exist", func() {
err := repo.RemoveUserLibrary(userID, library1.ID)
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(0))
})
})
})
Describe("Admin User Auto-Assignment", func() {
var (
libRepo model.LibraryRepository
library1 model.Library
library2 model.Library
)
BeforeEach(func() {
libRepo = NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
library1 = model.Library{ID: 201, Name: "Admin Test Library 1", Path: "/admin/test/path1"}
library2 = model.Library{ID: 202, 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{201, 202}})
// Clean up user-library associations for these test libraries
_, _ = repo.(*userRepository).executeSQL(squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{201, 202}}))
})
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).To(BeNil())
// Admin should automatically have access to all libraries (including existing ones)
libraries, err := repo.GetUserLibraries(adminUser.ID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(3)) // Our 2 test libraries + existing library ID 1
libIDs := make([]int, len(libraries))
for i, lib := range libraries {
libIDs[i] = lib.ID
}
Expect(libIDs).To(ContainElements(library1.ID, library2.ID, 1))
})
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(&regularUser)
Expect(err).To(BeNil())
// Give them access to just one library
err = repo.SetUserLibraries(regularUser.ID, []int{library1.ID})
Expect(err).To(BeNil())
// Promote to admin
regularUser.IsAdmin = true
err = repo.Put(&regularUser)
Expect(err).To(BeNil())
// Should now have access to all libraries (including existing ones)
libraries, err := repo.GetUserLibraries(regularUser.ID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(3)) // Our 2 test libraries + existing library ID 1
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, 1))
})
It("does not affect 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(&regularUser)
Expect(err).To(BeNil())
// Regular user should have no library associations
libraries, err := repo.GetUserLibraries(regularUser.ID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(0))
})
})
})