mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-04 06:35:52 +00:00
* test(plugins): speed up integration tests with shared wazero cache Reduce plugin test suite runtime from ~22s to ~12s by: - Creating a shared wazero compilation cache directory in TestPlugins() and setting conf.Server.CacheFolder globally so all test Manager instances reuse compiled WASM binaries from disk cache - Moving 6 createTestManager* calls from inside It blocks to BeforeAll blocks in scrobbler_adapter_test.go and manager_call_test.go - Replacing time.Sleep(2s) in KVStore TTL test with Eventually polling - Reducing WebSocket callback sleeps from 100ms to 10ms Signed-off-by: Deluan <deluan@navidrome.org> * test(plugins): enhance websocket tests by storing server messages for verification Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
589 lines
16 KiB
Go
589 lines
16 KiB
Go
//go:build !windows
|
|
|
|
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)
|
|
// Add test users
|
|
_ = 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))
|
|
|
|
// Verify that the correct fields are returned
|
|
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())
|
|
|
|
// Find alice
|
|
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() {
|
|
// Only allow access to user1 and user3
|
|
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)
|
|
// Add test users - alice is admin, bob and charlie are not
|
|
_ = 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() {
|
|
// Allow access to user1 (admin) and user2 (non-admin)
|
|
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() {
|
|
// Only allow access to non-admin users
|
|
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))
|
|
|
|
// Verify user names
|
|
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())
|
|
|
|
// Find alice
|
|
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())
|
|
|
|
// Find bob
|
|
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))
|
|
|
|
// Verify only alice and charlie are returned, not bob
|
|
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())
|
|
// Only alice (user1) is admin, charlie (user3) is not
|
|
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()
|
|
// Only allow user2 (bob) and user3 (charlie), both non-admins
|
|
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()
|
|
// Start with disabled plugin, no users configured
|
|
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()
|
|
|
|
// Update the plugin to have allUsers=true
|
|
err := manager.UpdatePluginUsers(ctx, "test-users", "", true)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Now enabling should succeed
|
|
err = manager.EnablePlugin(ctx, "test-users")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Verify plugin is loaded
|
|
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()
|
|
|
|
// First disable the plugin
|
|
err := manager.DisablePlugin(ctx, "test-users")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Update to have specific users (and allUsers=false)
|
|
err = manager.UpdatePluginUsers(ctx, "test-users", `["user1"]`, false)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Now enabling should succeed
|
|
err = manager.EnablePlugin(ctx, "test-users")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Verify plugin is loaded
|
|
manager.mu.RLock()
|
|
_, ok := manager.plugins["test-users"]
|
|
manager.mu.RUnlock()
|
|
Expect(ok).To(BeTrue())
|
|
})
|
|
})
|
|
})
|
|
|
|
// testUsersSetup contains common setup data for users integration tests
|
|
type testUsersSetup struct {
|
|
tmpDir string
|
|
destPath string
|
|
hashHex string
|
|
}
|
|
|
|
// setupTestUsersPlugin creates a temporary directory with the test-users plugin and returns setup info
|
|
func setupTestUsersPlugin() (*testUsersSetup, error) {
|
|
tmpDir, err := os.MkdirTemp("", "users-integration-test-*")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Copy the test-users plugin
|
|
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
|
|
}
|
|
|
|
// Compute SHA256 for the plugin
|
|
hash := sha256.Sum256(data)
|
|
hashHex := hex.EncodeToString(hash[:])
|
|
|
|
return &testUsersSetup{
|
|
tmpDir: tmpDir,
|
|
destPath: destPath,
|
|
hashHex: hashHex,
|
|
}, nil
|
|
}
|
|
|
|
// createTestUsers creates standard test users in the mock repo
|
|
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,
|
|
})
|
|
}
|
|
|
|
// setupTestUsersConfig sets up common plugin configuration
|
|
func setupTestUsersConfig(tmpDir string) {
|
|
conf.Server.Plugins.Enabled = true
|
|
conf.Server.Plugins.Folder = tmpDir
|
|
conf.Server.Plugins.AutoReload = false
|
|
}
|
|
|
|
// testUsersInput represents input for test-users plugin calls
|
|
type testUsersInput struct {
|
|
Operation string `json:"operation"`
|
|
}
|
|
|
|
// testUser represents a user returned from test-users plugin
|
|
type testUser struct {
|
|
UserName string `json:"userName"`
|
|
Name string `json:"name"`
|
|
IsAdmin bool `json:"isAdmin"`
|
|
}
|
|
|
|
// testUsersOutput represents output from test-users plugin
|
|
type testUsersOutput struct {
|
|
Users []testUser `json:"users,omitempty"`
|
|
Error *string `json:"error,omitempty"`
|
|
}
|
|
|
|
// callTestUsersPlugin calls the test-users plugin with given input
|
|
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
|
|
}
|
|
|
|
// setupUsersIntegrationManager creates a Manager for users integration tests with the given plugin settings.
|
|
// The plugin is enabled by default.
|
|
func setupUsersIntegrationManager(allUsers bool, allowedUsers string) (*Manager, func()) {
|
|
return setupUsersIntegrationManagerWithEnabled(true, allUsers, allowedUsers)
|
|
}
|
|
|
|
// setupUsersIntegrationManagerWithEnabled creates a Manager for users integration tests with full control over plugin state
|
|
func setupUsersIntegrationManagerWithEnabled(enabled, allUsers bool, allowedUsers string) (*Manager, func()) {
|
|
setup, err := setupTestUsersPlugin()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Setup config
|
|
cleanupConfig := configtest.SetupConfig()
|
|
setupTestUsersConfig(setup.tmpDir)
|
|
|
|
// Setup mock DataStore with plugin and users
|
|
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,
|
|
}
|
|
|
|
// Create and start manager
|
|
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
|
|
}
|