package plugins
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins/host"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("UsersService", Ordered, func() {
var (
ctx context.Context
ds model.DataStore
service host.UsersService
)
BeforeEach(func() {
ctx = GinkgoT().Context()
ds = &tests.MockDataStore{}
})
Describe("GetUsers", func() {
var mockUserRepo *tests.MockedUserRepo
BeforeEach(func() {
mockUserRepo = ds.User(ctx).(*tests.MockedUserRepo)
_ = mockUserRepo.Put(&model.User{
ID: "user1",
UserName: "alice",
Name: "Alice Admin",
IsAdmin: true,
})
_ = mockUserRepo.Put(&model.User{
ID: "user2",
UserName: "bob",
Name: "Bob User",
IsAdmin: false,
})
_ = mockUserRepo.Put(&model.User{
ID: "user3",
UserName: "charlie",
Name: "Charlie User",
IsAdmin: false,
})
})
Context("with allUsers=true", func() {
BeforeEach(func() {
service = newUsersService(ds, nil, true)
})
It("should return all users", func() {
users, err := service.GetUsers(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(users).To(HaveLen(3))
userNames := make([]string, len(users))
for i, u := range users {
userNames[i] = u.UserName
}
Expect(userNames).To(ContainElements("alice", "bob", "charlie"))
})
It("should return correct user properties", func() {
users, err := service.GetUsers(ctx)
Expect(err).ToNot(HaveOccurred())
var alice *host.User
for i := range users {
if users[i].UserName == "alice" {
alice = &users[i]
break
}
}
Expect(alice).ToNot(BeNil())
Expect(alice.UserName).To(Equal("alice"))
Expect(alice.Name).To(Equal("Alice Admin"))
Expect(alice.IsAdmin).To(BeTrue())
})
})
Context("with specific allowed users", func() {
BeforeEach(func() {
service = newUsersService(ds, []string{"user1", "user3"}, false)
})
It("should return only allowed users", func() {
users, err := service.GetUsers(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(users).To(HaveLen(2))
userNames := make([]string, len(users))
for i, u := range users {
userNames[i] = u.UserName
}
Expect(userNames).To(ContainElements("alice", "charlie"))
Expect(userNames).ToNot(ContainElement("bob"))
})
})
Context("with empty allowed users and allUsers=false", func() {
BeforeEach(func() {
service = newUsersService(ds, []string{}, false)
})
It("should return no users", func() {
users, err := service.GetUsers(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(users).To(BeEmpty())
})
})
Context("when datastore returns error", func() {
BeforeEach(func() {
mockUserRepo.Error = model.ErrNotFound
service = newUsersService(ds, nil, true)
})
It("should propagate the error", func() {
_, err := service.GetUsers(ctx)
Expect(err).To(HaveOccurred())
})
})
})
Describe("GetAdmins", func() {
var mockUserRepo *tests.MockedUserRepo
BeforeEach(func() {
mockUserRepo = ds.User(ctx).(*tests.MockedUserRepo)
_ = mockUserRepo.Put(&model.User{
ID: "user1",
UserName: "alice",
Name: "Alice Admin",
IsAdmin: true,
})
_ = mockUserRepo.Put(&model.User{
ID: "user2",
UserName: "bob",
Name: "Bob User",
IsAdmin: false,
})
_ = mockUserRepo.Put(&model.User{
ID: "user3",
UserName: "charlie",
Name: "Charlie User",
IsAdmin: false,
})
})
Context("with allUsers=true", func() {
BeforeEach(func() {
service = newUsersService(ds, nil, true)
})
It("should return only admin users", func() {
admins, err := service.GetAdmins(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(admins).To(HaveLen(1))
Expect(admins[0].UserName).To(Equal("alice"))
Expect(admins[0].IsAdmin).To(BeTrue())
})
})
Context("with specific allowed users including admin", func() {
BeforeEach(func() {
service = newUsersService(ds, []string{"user1", "user2"}, false)
})
It("should return only admin users from allowed list", func() {
admins, err := service.GetAdmins(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(admins).To(HaveLen(1))
Expect(admins[0].UserName).To(Equal("alice"))
})
})
Context("with specific allowed users excluding admin", func() {
BeforeEach(func() {
service = newUsersService(ds, []string{"user2", "user3"}, false)
})
It("should return empty when no admins in allowed list", func() {
admins, err := service.GetAdmins(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(admins).To(BeEmpty())
})
})
Context("when datastore returns error", func() {
BeforeEach(func() {
mockUserRepo.Error = model.ErrNotFound
service = newUsersService(ds, nil, true)
})
It("should propagate the error", func() {
_, err := service.GetAdmins(ctx)
Expect(err).To(HaveOccurred())
})
})
})
})
var _ = Describe("UsersService Integration", Ordered, func() {
var manager *Manager
BeforeAll(func() {
var cleanup func()
manager, cleanup = setupUsersIntegrationManager(true, "")
DeferCleanup(cleanup)
})
Describe("Plugin Loading", func() {
It("should load plugin with users permission", func() {
manager.mu.RLock()
p, ok := manager.plugins["test-users"]
manager.mu.RUnlock()
Expect(ok).To(BeTrue())
Expect(p.manifest.Permissions).ToNot(BeNil())
Expect(p.manifest.Permissions.Users).ToNot(BeNil())
})
})
Describe("Users Operations via Plugin", func() {
It("should get all users when allUsers is true", func() {
output, err := callTestUsersPlugin(GinkgoT().Context(), manager, testUsersInput{Operation: "get_users"})
Expect(err).ToNot(HaveOccurred())
Expect(output.Users).To(HaveLen(3))
userNames := make([]string, len(output.Users))
for i, u := range output.Users {
userNames[i] = u.UserName
}
Expect(userNames).To(ContainElements("alice", "bob", "charlie"))
})
It("should return correct user properties", func() {
output, err := callTestUsersPlugin(GinkgoT().Context(), manager, testUsersInput{Operation: "get_users"})
Expect(err).ToNot(HaveOccurred())
var alice *testUser
for i := range output.Users {
if output.Users[i].UserName == "alice" {
alice = &output.Users[i]
break
}
}
Expect(alice).ToNot(BeNil())
Expect(alice.UserName).To(Equal("alice"))
Expect(alice.Name).To(Equal("Alice Admin"))
Expect(alice.IsAdmin).To(BeTrue())
})
It("should return non-admin user correctly", func() {
output, err := callTestUsersPlugin(GinkgoT().Context(), manager, testUsersInput{Operation: "get_users"})
Expect(err).ToNot(HaveOccurred())
var bob *testUser
for i := range output.Users {
if output.Users[i].UserName == "bob" {
bob = &output.Users[i]
break
}
}
Expect(bob).ToNot(BeNil())
Expect(bob.UserName).To(Equal("bob"))
Expect(bob.Name).To(Equal("Bob User"))
Expect(bob.IsAdmin).To(BeFalse())
})
})
Describe("GetAdmins Operations via Plugin", func() {
It("should get only admin users when allUsers is true", func() {
output, err := callTestUsersPlugin(GinkgoT().Context(), manager, testUsersInput{Operation: "get_admins"})
Expect(err).ToNot(HaveOccurred())
Expect(output.Users).To(HaveLen(1))
Expect(output.Users[0].UserName).To(Equal("alice"))
Expect(output.Users[0].IsAdmin).To(BeTrue())
})
})
})
var _ = Describe("UsersService Integration with Specific Users", Ordered, func() {
var manager *Manager
BeforeAll(func() {
var cleanup func()
manager, cleanup = setupUsersIntegrationManager(false, `["user1", "user3"]`)
DeferCleanup(cleanup)
})
Describe("Users Operations with Specific Allowed Users", func() {
It("should only return allowed users", func() {
output, err := callTestUsersPlugin(GinkgoT().Context(), manager, testUsersInput{Operation: "get_users"})
Expect(err).ToNot(HaveOccurred())
Expect(output.Users).To(HaveLen(2))
userNames := make([]string, len(output.Users))
for i, u := range output.Users {
userNames[i] = u.UserName
}
Expect(userNames).To(ContainElements("alice", "charlie"))
Expect(userNames).ToNot(ContainElement("bob"))
})
It("should only return admin users from allowed list via GetAdmins", func() {
output, err := callTestUsersPlugin(GinkgoT().Context(), manager, testUsersInput{Operation: "get_admins"})
Expect(err).ToNot(HaveOccurred())
Expect(output.Users).To(HaveLen(1))
Expect(output.Users[0].UserName).To(Equal("alice"))
Expect(output.Users[0].IsAdmin).To(BeTrue())
})
})
})
var _ = Describe("UsersService Integration GetAdmins with No Admins", Ordered, func() {
var manager *Manager
BeforeAll(func() {
var cleanup func()
manager, cleanup = setupUsersIntegrationManager(false, `["user2", "user3"]`)
DeferCleanup(cleanup)
})
Describe("GetAdmins with no admin users in allowed list", func() {
It("should return empty when no admins in allowed list", func() {
output, err := callTestUsersPlugin(GinkgoT().Context(), manager, testUsersInput{Operation: "get_admins"})
Expect(err).ToNot(HaveOccurred())
Expect(output.Users).To(BeEmpty())
})
})
})
var _ = Describe("UsersService Enable Gate", Ordered, func() {
var manager *Manager
BeforeAll(func() {
var cleanup func()
manager, cleanup = setupUsersIntegrationManagerWithEnabled(false, false, "")
DeferCleanup(cleanup)
})
Describe("Enable Gate Behavior", func() {
It("should block enabling when no users configured and allUsers is false", func() {
ctx := GinkgoT().Context()
err := manager.EnablePlugin(ctx, "test-users")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("users permission requires configuration"))
})
It("should allow enabling when allUsers is true", func() {
ctx := GinkgoT().Context()
err := manager.UpdatePluginUsers(ctx, "test-users", "", true)
Expect(err).ToNot(HaveOccurred())
err = manager.EnablePlugin(ctx, "test-users")
Expect(err).ToNot(HaveOccurred())
manager.mu.RLock()
_, ok := manager.plugins["test-users"]
manager.mu.RUnlock()
Expect(ok).To(BeTrue())
})
It("should allow enabling when specific users are configured", func() {
ctx := GinkgoT().Context()
err := manager.DisablePlugin(ctx, "test-users")
Expect(err).ToNot(HaveOccurred())
err = manager.UpdatePluginUsers(ctx, "test-users", `["user1"]`, false)
Expect(err).ToNot(HaveOccurred())
err = manager.EnablePlugin(ctx, "test-users")
Expect(err).ToNot(HaveOccurred())
manager.mu.RLock()
_, ok := manager.plugins["test-users"]
manager.mu.RUnlock()
Expect(ok).To(BeTrue())
})
})
})
type testUsersSetup struct {
tmpDir string
destPath string
hashHex string
}
func setupTestUsersPlugin() (*testUsersSetup, error) {
tmpDir, err := os.MkdirTemp("", "users-integration-test-*")
if err != nil {
return nil, err
}
srcPath := filepath.Join(testdataDir, "test-users"+PackageExtension)
destPath := filepath.Join(tmpDir, "test-users"+PackageExtension)
data, err := os.ReadFile(srcPath)
if err != nil {
_ = os.RemoveAll(tmpDir)
return nil, err
}
if err := os.WriteFile(destPath, data, 0600); err != nil {
_ = os.RemoveAll(tmpDir)
return nil, err
}
hash := sha256.Sum256(data)
hashHex := hex.EncodeToString(hash[:])
return &testUsersSetup{
tmpDir: tmpDir,
destPath: destPath,
hashHex: hashHex,
}, nil
}
func createTestUsers(mockUserRepo *tests.MockedUserRepo) {
_ = mockUserRepo.Put(&model.User{
ID: "user1",
UserName: "alice",
Name: "Alice Admin",
IsAdmin: true,
})
_ = mockUserRepo.Put(&model.User{
ID: "user2",
UserName: "bob",
Name: "Bob User",
IsAdmin: false,
})
_ = mockUserRepo.Put(&model.User{
ID: "user3",
UserName: "charlie",
Name: "Charlie User",
IsAdmin: false,
})
}
func setupTestUsersConfig(tmpDir string) {
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
}
type testUsersInput struct {
Operation string `json:"operation"`
}
type testUser struct {
UserName string `json:"userName"`
Name string `json:"name"`
IsAdmin bool `json:"isAdmin"`
}
type testUsersOutput struct {
Users []testUser `json:"users,omitempty"`
Error *string `json:"error,omitempty"`
}
func callTestUsersPlugin(ctx context.Context, manager *Manager, input testUsersInput) (*testUsersOutput, error) {
manager.mu.RLock()
p := manager.plugins["test-users"]
manager.mu.RUnlock()
instance, err := p.instance(ctx)
if err != nil {
return nil, err
}
defer instance.Close(ctx)
inputBytes, _ := json.Marshal(input)
_, outputBytes, err := instance.Call("nd_test_users", inputBytes)
if err != nil {
return nil, err
}
var output testUsersOutput
if err := json.Unmarshal(outputBytes, &output); err != nil {
return nil, err
}
if output.Error != nil {
return nil, errors.New(*output.Error)
}
return &output, nil
}
func setupUsersIntegrationManager(allUsers bool, allowedUsers string) (*Manager, func()) {
return setupUsersIntegrationManagerWithEnabled(true, allUsers, allowedUsers)
}
func setupUsersIntegrationManagerWithEnabled(enabled, allUsers bool, allowedUsers string) (*Manager, func()) {
setup, err := setupTestUsersPlugin()
Expect(err).ToNot(HaveOccurred())
cleanupConfig := configtest.SetupConfig()
setupTestUsersConfig(setup.tmpDir)
mockPluginRepo := tests.CreateMockPluginRepo()
mockPluginRepo.Permitted = true
mockPluginRepo.SetData(model.Plugins{{
ID: "test-users",
Path: setup.destPath,
SHA256: setup.hashHex,
Enabled: enabled,
AllUsers: allUsers,
Users: allowedUsers,
}})
mockUserRepo := tests.CreateMockUserRepo()
createTestUsers(mockUserRepo)
dataStore := &tests.MockDataStore{
MockedPlugin: mockPluginRepo,
MockedUser: mockUserRepo,
}
manager := &Manager{
plugins: make(map[string]*plugin),
ds: dataStore,
subsonicRouter: http.NotFoundHandler(),
}
err = manager.Start(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
cleanup := func() {
_ = manager.Stop()
_ = os.RemoveAll(setup.tmpDir)
cleanupConfig()
}
return manager, cleanup
}