mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
refactor(plugins): streamline user access management in plugin services
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
9f7b6870ac
commit
b0f91715b9
@ -239,11 +239,13 @@ type inspectOptions struct {
|
||||
}
|
||||
|
||||
type pluginsOptions struct {
|
||||
Enabled bool
|
||||
Folder string
|
||||
CacheSize string
|
||||
AutoReload bool
|
||||
LogLevel string
|
||||
Enabled bool
|
||||
Folder string
|
||||
CacheSize string
|
||||
AutoReload bool
|
||||
LogLevel string
|
||||
EndpointRequestLimit int
|
||||
EndpointRequestWindow time.Duration
|
||||
}
|
||||
|
||||
type extAuthOptions struct {
|
||||
@ -671,6 +673,8 @@ func setViperDefaults() {
|
||||
viper.SetDefault("plugins.enabled", true)
|
||||
viper.SetDefault("plugins.cachesize", "200MB")
|
||||
viper.SetDefault("plugins.autoreload", false)
|
||||
viper.SetDefault("plugins.endpointrequestlimit", 60)
|
||||
viper.SetDefault("plugins.endpointrequestwindow", time.Minute)
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
|
||||
@ -17,6 +17,7 @@ type HTTPHandleRequest struct {
|
||||
Method string `json:"method"`
|
||||
// Path is the request path relative to the plugin's base URL.
|
||||
// For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook".
|
||||
// Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "".
|
||||
Path string `json:"path"`
|
||||
// Query is the raw query string without the leading '?'.
|
||||
Query string `json:"query,omitempty"`
|
||||
|
||||
@ -26,27 +26,19 @@ const subsonicAPIVersion = "1.16.1"
|
||||
// URL Format: Only the path and query parameters are used - host/protocol are ignored.
|
||||
// Automatic Parameters: The service adds 'c' (client), 'v' (version), and optionally 'f' (format).
|
||||
type subsonicAPIServiceImpl struct {
|
||||
pluginID string
|
||||
router SubsonicRouter
|
||||
ds model.DataStore
|
||||
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
|
||||
allUsers bool // If true, plugin can access all users
|
||||
userIDMap map[string]struct{}
|
||||
pluginName string
|
||||
router SubsonicRouter
|
||||
ds model.DataStore
|
||||
userAccess UserAccess
|
||||
}
|
||||
|
||||
// newSubsonicAPIService creates a new SubsonicAPIService for a plugin.
|
||||
func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, allowedUserIDs []string, allUsers bool) host.SubsonicAPIService {
|
||||
userIDMap := make(map[string]struct{})
|
||||
for _, id := range allowedUserIDs {
|
||||
userIDMap[id] = struct{}{}
|
||||
}
|
||||
func newSubsonicAPIService(pluginName string, router SubsonicRouter, ds model.DataStore, userAccess UserAccess) host.SubsonicAPIService {
|
||||
return &subsonicAPIServiceImpl{
|
||||
pluginID: pluginID,
|
||||
router: router,
|
||||
ds: ds,
|
||||
allowedUserIDs: allowedUserIDs,
|
||||
allUsers: allUsers,
|
||||
userIDMap: userIDMap,
|
||||
pluginName: pluginName,
|
||||
router: router,
|
||||
ds: ds,
|
||||
userAccess: userAccess,
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,12 +66,12 @@ func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string,
|
||||
}
|
||||
|
||||
if err := s.checkPermissions(ctx, username); err != nil {
|
||||
log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err)
|
||||
log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginName, "user", username, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add required Subsonic API parameters
|
||||
query.Set("c", s.pluginID) // Client name (plugin ID)
|
||||
query.Set("c", s.pluginName) // Client name (plugin ID)
|
||||
query.Set("v", subsonicAPIVersion) // API version
|
||||
if setJSON {
|
||||
query.Set("f", "json") // Response format
|
||||
@ -135,14 +127,13 @@ func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (strin
|
||||
}
|
||||
|
||||
func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error {
|
||||
// If allUsers is true, allow any user
|
||||
if s.allUsers {
|
||||
if s.userAccess.allUsers {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Must have at least one allowed user ID configured
|
||||
if len(s.allowedUserIDs) == 0 {
|
||||
return fmt.Errorf("no users configured for plugin %s", s.pluginID)
|
||||
// Must have at least one allowed user configured
|
||||
if !s.userAccess.HasConfiguredUsers() {
|
||||
return fmt.Errorf("no users configured for plugin %s", s.pluginName)
|
||||
}
|
||||
|
||||
// Look up the user by username to get their ID
|
||||
@ -155,7 +146,7 @@ func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username
|
||||
}
|
||||
|
||||
// Check if the user's ID is in the allowed list
|
||||
if _, ok := s.userIDMap[usr.ID]; !ok {
|
||||
if !s.userAccess.IsAllowed(usr.ID) {
|
||||
return fmt.Errorf("user %s is not authorized for this plugin", username)
|
||||
}
|
||||
|
||||
|
||||
@ -268,7 +268,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
Context("with specific user IDs allowed", func() {
|
||||
It("blocks users not in the allowed list", func() {
|
||||
// allowedUserIDs contains "user2", but testuser is "user1"
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user2"}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping?u=testuser")
|
||||
@ -278,7 +278,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
It("allows users in the allowed list", func() {
|
||||
// allowedUserIDs contains "user2" which is "alloweduser"
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user2"}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
response, err := service.Call(ctx, "/ping?u=alloweduser")
|
||||
@ -288,7 +288,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
It("blocks admin users when not in allowed list", func() {
|
||||
// allowedUserIDs only contains "user1" (testuser), not "admin1"
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user1"}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping?u=adminuser")
|
||||
@ -298,7 +298,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
It("allows admin users when in allowed list", func() {
|
||||
// allowedUserIDs contains "admin1"
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"admin1"}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"admin1"}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
response, err := service.Call(ctx, "/ping?u=adminuser")
|
||||
@ -309,7 +309,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
Context("with allUsers=true", func() {
|
||||
It("allows all users regardless of allowed list", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
response, err := service.Call(ctx, "/ping?u=testuser")
|
||||
@ -318,7 +318,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("allows admin users when allUsers is true", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
response, err := service.Call(ctx, "/ping?u=adminuser")
|
||||
@ -329,7 +329,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
Context("with no users configured", func() {
|
||||
It("returns error when no users are configured", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping?u=testuser")
|
||||
@ -338,7 +338,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("returns error for empty user list", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping?u=testuser")
|
||||
@ -350,7 +350,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
Describe("URL Handling", func() {
|
||||
It("returns error for missing username parameter", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping")
|
||||
@ -359,7 +359,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("returns error for invalid URL", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "://invalid")
|
||||
@ -368,7 +368,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("extracts endpoint from path correctly", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user1"}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/rest/ping.view?u=testuser")
|
||||
@ -381,7 +381,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
Describe("CallRaw", func() {
|
||||
It("returns binary data and content-type", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
||||
@ -391,7 +391,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("does not set f=json parameter", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
||||
@ -403,7 +403,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("enforces permission checks", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user2"}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
||||
@ -412,7 +412,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("returns error when username is missing", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "/getCoverArt")
|
||||
@ -421,7 +421,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("returns error when router is nil", func() {
|
||||
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", nil, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser")
|
||||
@ -430,7 +430,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("returns error for invalid URL", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "://invalid")
|
||||
@ -441,7 +441,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
Describe("Router Availability", func() {
|
||||
It("returns error when router is nil", func() {
|
||||
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", nil, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping?u=testuser")
|
||||
|
||||
@ -9,16 +9,14 @@ import (
|
||||
)
|
||||
|
||||
type usersServiceImpl struct {
|
||||
ds model.DataStore
|
||||
allowedUsers []string // User IDs this plugin can access
|
||||
allUsers bool // If true, plugin can access all users
|
||||
ds model.DataStore
|
||||
userAccess UserAccess
|
||||
}
|
||||
|
||||
func newUsersService(ds model.DataStore, allowedUsers []string, allUsers bool) host.UsersService {
|
||||
func newUsersService(ds model.DataStore, userAccess UserAccess) host.UsersService {
|
||||
return &usersServiceImpl{
|
||||
ds: ds,
|
||||
allowedUsers: allowedUsers,
|
||||
allUsers: allUsers,
|
||||
ds: ds,
|
||||
userAccess: userAccess,
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,17 +26,9 @@ func (s *usersServiceImpl) GetUsers(ctx context.Context) ([]host.User, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build allowed users map for efficient lookup
|
||||
allowedMap := make(map[string]bool, len(s.allowedUsers))
|
||||
for _, id := range s.allowedUsers {
|
||||
allowedMap[id] = true
|
||||
}
|
||||
|
||||
var result []host.User
|
||||
for _, u := range users {
|
||||
// If allUsers is true, include all users
|
||||
// Otherwise, only include users in the allowed list
|
||||
if s.allUsers || allowedMap[u.ID] {
|
||||
if s.userAccess.IsAllowed(u.ID) {
|
||||
result = append(result, host.User{
|
||||
UserName: u.UserName,
|
||||
Name: u.Name,
|
||||
|
||||
@ -61,7 +61,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
|
||||
Context("with allUsers=true", func() {
|
||||
BeforeEach(func() {
|
||||
service = newUsersService(ds, nil, true)
|
||||
service = newUsersService(ds, NewUserAccess(true, nil))
|
||||
})
|
||||
|
||||
It("should return all users", func() {
|
||||
@ -100,7 +100,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
Context("with specific allowed users", func() {
|
||||
BeforeEach(func() {
|
||||
// Only allow access to user1 and user3
|
||||
service = newUsersService(ds, []string{"user1", "user3"}, false)
|
||||
service = newUsersService(ds, NewUserAccess(false, []string{"user1", "user3"}))
|
||||
})
|
||||
|
||||
It("should return only allowed users", func() {
|
||||
@ -119,7 +119,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
|
||||
Context("with empty allowed users and allUsers=false", func() {
|
||||
BeforeEach(func() {
|
||||
service = newUsersService(ds, []string{}, false)
|
||||
service = newUsersService(ds, NewUserAccess(false, []string{}))
|
||||
})
|
||||
|
||||
It("should return no users", func() {
|
||||
@ -132,7 +132,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
Context("when datastore returns error", func() {
|
||||
BeforeEach(func() {
|
||||
mockUserRepo.Error = model.ErrNotFound
|
||||
service = newUsersService(ds, nil, true)
|
||||
service = newUsersService(ds, NewUserAccess(true, nil))
|
||||
})
|
||||
|
||||
It("should propagate the error", func() {
|
||||
@ -170,7 +170,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
|
||||
Context("with allUsers=true", func() {
|
||||
BeforeEach(func() {
|
||||
service = newUsersService(ds, nil, true)
|
||||
service = newUsersService(ds, NewUserAccess(true, nil))
|
||||
})
|
||||
|
||||
It("should return only admin users", func() {
|
||||
@ -185,7 +185,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
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)
|
||||
service = newUsersService(ds, NewUserAccess(false, []string{"user1", "user2"}))
|
||||
})
|
||||
|
||||
It("should return only admin users from allowed list", func() {
|
||||
@ -199,7 +199,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
Context("with specific allowed users excluding admin", func() {
|
||||
BeforeEach(func() {
|
||||
// Only allow access to non-admin users
|
||||
service = newUsersService(ds, []string{"user2", "user3"}, false)
|
||||
service = newUsersService(ds, NewUserAccess(false, []string{"user2", "user3"}))
|
||||
})
|
||||
|
||||
It("should return empty when no admins in allowed list", func() {
|
||||
@ -212,7 +212,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
Context("when datastore returns error", func() {
|
||||
BeforeEach(func() {
|
||||
mockUserRepo.Error = model.ErrNotFound
|
||||
service = newUsersService(ds, nil, true)
|
||||
service = newUsersService(ds, NewUserAccess(true, nil))
|
||||
})
|
||||
|
||||
It("should propagate the error", func() {
|
||||
|
||||
@ -3,9 +3,10 @@ package plugins
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/httprate"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@ -28,6 +29,12 @@ type NativeAuthMiddleware func(ds model.DataStore) func(next http.Handler) http.
|
||||
// at runtime. Plugin lookup happens per-request under RLock.
|
||||
func NewEndpointRouter(manager *Manager, ds model.DataStore, subsonicAuth SubsonicAuthValidator, nativeAuth NativeAuthMiddleware) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Apply rate limiting if configured
|
||||
if conf.Server.Plugins.EndpointRequestLimit > 0 {
|
||||
r.Use(httprate.LimitByIP(conf.Server.Plugins.EndpointRequestLimit, conf.Server.Plugins.EndpointRequestWindow))
|
||||
}
|
||||
|
||||
h := &endpointHandler{
|
||||
manager: manager,
|
||||
ds: ds,
|
||||
@ -104,7 +111,7 @@ func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *pl
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !p.allUsers && !isUserAllowed(p.allowedUserIDs, user.ID) {
|
||||
if !p.userAccess.IsAllowed(user.ID) {
|
||||
log.Warn(ctx, "Plugin endpoint access denied", "plugin", p.name, "user", user.UserName)
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
@ -120,8 +127,9 @@ func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *pl
|
||||
}
|
||||
|
||||
// Build the plugin request
|
||||
// Normalize path: both /ext/plugin and /ext/plugin/ map to ""
|
||||
relPath := "/" + chi.URLParam(r, "*")
|
||||
if relPath == "/" {
|
||||
if relPath == "/" || relPath == "" {
|
||||
relPath = ""
|
||||
}
|
||||
|
||||
@ -177,8 +185,3 @@ func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *pl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isUserAllowed checks if the given user ID is in the allowed list.
|
||||
func isUserAllowed(allowedIDs []string, userID string) bool {
|
||||
return slices.Contains(allowedIDs, userID)
|
||||
}
|
||||
|
||||
@ -72,8 +72,8 @@ var _ = Describe("HTTP Endpoint Handler", Ordered, func() {
|
||||
tmpDir, err = os.MkdirTemp("", "http-endpoint-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Copy both test plugins
|
||||
for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public"} {
|
||||
// Copy all test plugins
|
||||
for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public", "test-http-endpoint-native"} {
|
||||
srcPath := filepath.Join(testdataDir, pluginName+PackageExtension)
|
||||
destPath := filepath.Join(tmpDir, pluginName+PackageExtension)
|
||||
data, err := os.ReadFile(srcPath)
|
||||
@ -109,7 +109,7 @@ var _ = Describe("HTTP Endpoint Handler", Ordered, func() {
|
||||
|
||||
// Build enabled plugins list
|
||||
var enabledPlugins model.Plugins
|
||||
for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public"} {
|
||||
for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public", "test-http-endpoint-native"} {
|
||||
pluginPath := filepath.Join(tmpDir, pluginName+PackageExtension)
|
||||
data, err := os.ReadFile(pluginPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -162,6 +162,18 @@ var _ = Describe("HTTP Endpoint Handler", Ordered, func() {
|
||||
Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("loads the native auth endpoint plugin", func() {
|
||||
manager.mu.RLock()
|
||||
p := manager.plugins["test-http-endpoint-native"]
|
||||
manager.mu.RUnlock()
|
||||
|
||||
Expect(p).ToNot(BeNil())
|
||||
Expect(p.manifest.Name).To(Equal("Test HTTP Endpoint Native Plugin"))
|
||||
Expect(p.manifest.Permissions.Endpoints).ToNot(BeNil())
|
||||
Expect(string(p.manifest.Permissions.Endpoints.Auth)).To(Equal("native"))
|
||||
Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("loads the public endpoint plugin", func() {
|
||||
manager.mu.RLock()
|
||||
p := manager.plugins["test-http-endpoint-public"]
|
||||
@ -239,6 +251,55 @@ var _ = Describe("HTTP Endpoint Handler", Ordered, func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Native Auth Endpoints", func() {
|
||||
It("returns hello response with valid native auth", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil)
|
||||
req.Header.Set("X-Test-User", "testuser")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(Equal("Hello from native auth plugin!"))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("text/plain"))
|
||||
})
|
||||
|
||||
It("returns echo response with user details", func() {
|
||||
req := httptest.NewRequest("POST", "/test-http-endpoint-native/echo?foo=bar", strings.NewReader("native body"))
|
||||
req.Header.Set("X-Test-User", "adminuser")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp["method"]).To(Equal("POST"))
|
||||
Expect(resp["path"]).To(Equal("/echo"))
|
||||
Expect(resp["body"]).To(Equal("native body"))
|
||||
Expect(resp["hasUser"]).To(BeTrue())
|
||||
Expect(resp["username"]).To(Equal("adminuser"))
|
||||
})
|
||||
|
||||
It("returns 401 without auth header", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("returns 401 with invalid auth header", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil)
|
||||
req.Header.Set("X-Test-User", "nonexistent")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Public Endpoints (auth: none)", func() {
|
||||
It("returns webhook response without auth", func() {
|
||||
req := httptest.NewRequest("POST", "/test-http-endpoint-public/webhook", nil)
|
||||
|
||||
@ -260,19 +260,10 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Build user ID map for fast lookups
|
||||
userIDMap := make(map[string]struct{})
|
||||
for _, id := range plugin.allowedUserIDs {
|
||||
userIDMap[id] = struct{}{}
|
||||
}
|
||||
|
||||
// Create a new scrobbler adapter for this plugin with user authorization config
|
||||
// Create a new scrobbler adapter for this plugin
|
||||
return &ScrobblerPlugin{
|
||||
name: plugin.name,
|
||||
plugin: plugin,
|
||||
allowedUserIDs: plugin.allowedUserIDs,
|
||||
allUsers: plugin.allUsers,
|
||||
userIDMap: userIDMap,
|
||||
name: plugin.name,
|
||||
plugin: plugin,
|
||||
}, true
|
||||
}
|
||||
|
||||
|
||||
@ -24,10 +24,9 @@ type serviceContext struct {
|
||||
manager *Manager
|
||||
permissions *Permissions
|
||||
config map[string]string
|
||||
allowedUsers []string // User IDs this plugin can access
|
||||
allUsers bool // If true, plugin can access all users
|
||||
allowedLibraries []int // Library IDs this plugin can access
|
||||
allLibraries bool // If true, plugin can access all libraries
|
||||
userAccess UserAccess // User authorization for this plugin
|
||||
allowedLibraries []int // Library IDs this plugin can access
|
||||
allLibraries bool // If true, plugin can access all libraries
|
||||
}
|
||||
|
||||
// hostServiceEntry defines a host service for table-driven registration.
|
||||
@ -52,7 +51,7 @@ var hostServices = []hostServiceEntry{
|
||||
name: "SubsonicAPI",
|
||||
hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil },
|
||||
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
||||
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
|
||||
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.userAccess)
|
||||
return host.RegisterSubsonicAPIHostFunctions(service), nil
|
||||
},
|
||||
},
|
||||
@ -115,7 +114,7 @@ var hostServices = []hostServiceEntry{
|
||||
name: "Users",
|
||||
hasPermission: func(p *Permissions) bool { return p != nil && p.Users != nil },
|
||||
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
||||
service := newUsersService(ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
|
||||
service := newUsersService(ctx.manager.ds, ctx.userAccess)
|
||||
return host.RegisterUsersHostFunctions(service), nil
|
||||
},
|
||||
},
|
||||
@ -302,13 +301,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
var hostFunctions []extism.HostFunction
|
||||
var closers []io.Closer
|
||||
|
||||
userAccess := NewUserAccess(p.AllUsers, allowedUsers)
|
||||
|
||||
svcCtx := &serviceContext{
|
||||
pluginName: p.ID,
|
||||
manager: m,
|
||||
permissions: pkg.Manifest.Permissions,
|
||||
config: pluginConfig,
|
||||
allowedUsers: allowedUsers,
|
||||
allUsers: p.AllUsers,
|
||||
userAccess: userAccess,
|
||||
allowedLibraries: allowedLibraries,
|
||||
allLibraries: p.AllLibraries,
|
||||
}
|
||||
@ -361,15 +361,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
|
||||
m.mu.Lock()
|
||||
m.plugins[p.ID] = &plugin{
|
||||
name: p.ID,
|
||||
path: p.Path,
|
||||
manifest: pkg.Manifest,
|
||||
compiled: compiled,
|
||||
capabilities: capabilities,
|
||||
closers: closers,
|
||||
metrics: m.metrics,
|
||||
allowedUserIDs: allowedUsers,
|
||||
allUsers: p.AllUsers,
|
||||
name: p.ID,
|
||||
path: p.Path,
|
||||
manifest: pkg.Manifest,
|
||||
compiled: compiled,
|
||||
capabilities: capabilities,
|
||||
closers: closers,
|
||||
metrics: m.metrics,
|
||||
userAccess: userAccess,
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
|
||||
@ -12,15 +12,14 @@ import (
|
||||
|
||||
// plugin represents a loaded plugin
|
||||
type plugin struct {
|
||||
name string // Plugin name (from filename)
|
||||
path string // Path to the wasm file
|
||||
manifest *Manifest
|
||||
compiled *extism.CompiledPlugin
|
||||
capabilities []Capability // Auto-detected capabilities based on exported functions
|
||||
closers []io.Closer // Cleanup functions to call on unload
|
||||
metrics PluginMetricsRecorder
|
||||
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
|
||||
allUsers bool // If true, plugin can access all users
|
||||
name string // Plugin name (from filename)
|
||||
path string // Path to the wasm file
|
||||
manifest *Manifest
|
||||
compiled *extism.CompiledPlugin
|
||||
capabilities []Capability // Auto-detected capabilities based on exported functions
|
||||
closers []io.Closer // Cleanup functions to call on unload
|
||||
metrics PluginMetricsRecorder
|
||||
userAccess UserAccess // User authorization for this plugin
|
||||
}
|
||||
|
||||
// instance creates a new plugin instance for the given context.
|
||||
|
||||
@ -33,11 +33,8 @@ func init() {
|
||||
// ScrobblerPlugin is an adapter that wraps an Extism plugin and implements
|
||||
// the scrobbler.Scrobbler interface for scrobbling to external services.
|
||||
type ScrobblerPlugin struct {
|
||||
name string
|
||||
plugin *plugin
|
||||
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
|
||||
allUsers bool // If true, plugin can access all users
|
||||
userIDMap map[string]struct{} // Cached map for fast lookups
|
||||
name string
|
||||
plugin *plugin
|
||||
}
|
||||
|
||||
// IsAuthorized checks if the user is authorized with this scrobbler.
|
||||
@ -45,7 +42,7 @@ type ScrobblerPlugin struct {
|
||||
// then delegates to the plugin for service-specific authorization.
|
||||
func (s *ScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
// First check server-side authorization based on plugin configuration
|
||||
if !s.isUserAllowed(userId) {
|
||||
if !s.plugin.userAccess.IsAllowed(userId) {
|
||||
return false
|
||||
}
|
||||
|
||||
@ -63,18 +60,6 @@ func (s *ScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool
|
||||
return result
|
||||
}
|
||||
|
||||
// isUserAllowed checks if the given user ID is allowed to use this plugin.
|
||||
func (s *ScrobblerPlugin) isUserAllowed(userId string) bool {
|
||||
if s.allUsers {
|
||||
return true
|
||||
}
|
||||
if len(s.allowedUserIDs) == 0 {
|
||||
return false
|
||||
}
|
||||
_, ok := s.userIDMap[userId]
|
||||
return ok
|
||||
}
|
||||
|
||||
// NowPlaying sends a now playing notification to the scrobbler
|
||||
func (s *ScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
||||
username := getUsernameFromContext(ctx)
|
||||
|
||||
@ -71,41 +71,6 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("isUserAllowed", func() {
|
||||
It("returns true when allUsers is true", func() {
|
||||
sp := &ScrobblerPlugin{allUsers: true}
|
||||
Expect(sp.isUserAllowed("any-user")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false when allowedUserIDs is empty and allUsers is false", func() {
|
||||
sp := &ScrobblerPlugin{allUsers: false, allowedUserIDs: []string{}}
|
||||
Expect(sp.isUserAllowed("user-1")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false when allowedUserIDs is nil and allUsers is false", func() {
|
||||
sp := &ScrobblerPlugin{allUsers: false}
|
||||
Expect(sp.isUserAllowed("user-1")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns true when user is in allowedUserIDs", func() {
|
||||
sp := &ScrobblerPlugin{
|
||||
allUsers: false,
|
||||
allowedUserIDs: []string{"user-1", "user-2"},
|
||||
userIDMap: map[string]struct{}{"user-1": {}, "user-2": {}},
|
||||
}
|
||||
Expect(sp.isUserAllowed("user-1")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false when user is not in allowedUserIDs", func() {
|
||||
sp := &ScrobblerPlugin{
|
||||
allUsers: false,
|
||||
allowedUserIDs: []string{"user-1", "user-2"},
|
||||
userIDMap: map[string]struct{}{"user-1": {}, "user-2": {}},
|
||||
}
|
||||
Expect(sp.isUserAllowed("user-3")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NowPlaying", func() {
|
||||
It("successfully calls the plugin", func() {
|
||||
track := &model.MediaFile{
|
||||
|
||||
16
plugins/testdata/test-http-endpoint-native/go.mod
vendored
Normal file
16
plugins/testdata/test-http-endpoint-native/go.mod
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
module test-http-endpoint-native
|
||||
|
||||
go 1.25
|
||||
|
||||
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/extism/go-pdk v1.1.3 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
|
||||
14
plugins/testdata/test-http-endpoint-native/go.sum
vendored
Normal file
14
plugins/testdata/test-http-endpoint-native/go.sum
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
|
||||
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
61
plugins/testdata/test-http-endpoint-native/main.go
vendored
Normal file
61
plugins/testdata/test-http-endpoint-native/main.go
vendored
Normal file
@ -0,0 +1,61 @@
|
||||
// Test plugin for native auth (JWT) HTTP endpoint integration tests.
|
||||
// Build with: tinygo build -o ../test-http-endpoint-native.wasm -target wasip1 -buildmode=c-shared .
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/httpendpoint"
|
||||
)
|
||||
|
||||
func init() {
|
||||
httpendpoint.Register(&testNativeEndpoint{})
|
||||
}
|
||||
|
||||
type testNativeEndpoint struct{}
|
||||
|
||||
func (t *testNativeEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (httpendpoint.HTTPHandleResponse, error) {
|
||||
switch req.Path {
|
||||
case "/hello":
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 200,
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"text/plain"},
|
||||
},
|
||||
Body: "Hello from native auth plugin!",
|
||||
}, nil
|
||||
|
||||
case "/echo":
|
||||
// Echo back the request as JSON
|
||||
data, _ := json.Marshal(map[string]any{
|
||||
"method": req.Method,
|
||||
"path": req.Path,
|
||||
"query": req.Query,
|
||||
"body": req.Body,
|
||||
"hasUser": req.User != nil,
|
||||
"username": userName(req.User),
|
||||
})
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 200,
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
},
|
||||
Body: string(data),
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 404,
|
||||
Body: "Not found: " + req.Path,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func userName(u *httpendpoint.HTTPUser) string {
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
return u.Username
|
||||
}
|
||||
|
||||
func main() {}
|
||||
16
plugins/testdata/test-http-endpoint-native/manifest.json
vendored
Normal file
16
plugins/testdata/test-http-endpoint-native/manifest.json
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Test HTTP Endpoint Native Plugin",
|
||||
"author": "Navidrome Test",
|
||||
"version": "1.0.0",
|
||||
"description": "Test plugin for native (JWT) HTTP endpoint integration testing",
|
||||
"permissions": {
|
||||
"endpoints": {
|
||||
"auth": "native",
|
||||
"paths": ["/hello", "/echo"],
|
||||
"reason": "Testing native auth HTTP endpoint handling"
|
||||
},
|
||||
"users": {
|
||||
"reason": "Authenticated endpoints require user access"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
plugins/user_access.go
Normal file
35
plugins/user_access.go
Normal file
@ -0,0 +1,35 @@
|
||||
package plugins
|
||||
|
||||
// UserAccess encapsulates user authorization for a plugin,
|
||||
// determining which users are allowed to interact with it.
|
||||
type UserAccess struct {
|
||||
allUsers bool
|
||||
userIDMap map[string]struct{}
|
||||
}
|
||||
|
||||
// NewUserAccess creates a UserAccess from the plugin's configuration.
|
||||
// If allUsers is true, all users are allowed regardless of the list.
|
||||
func NewUserAccess(allUsers bool, userIDs []string) UserAccess {
|
||||
userIDMap := make(map[string]struct{}, len(userIDs))
|
||||
for _, id := range userIDs {
|
||||
userIDMap[id] = struct{}{}
|
||||
}
|
||||
return UserAccess{
|
||||
allUsers: allUsers,
|
||||
userIDMap: userIDMap,
|
||||
}
|
||||
}
|
||||
|
||||
// IsAllowed checks if the given user ID is permitted.
|
||||
func (ua UserAccess) IsAllowed(userID string) bool {
|
||||
if ua.allUsers {
|
||||
return true
|
||||
}
|
||||
_, ok := ua.userIDMap[userID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// HasConfiguredUsers reports whether any specific user IDs have been configured.
|
||||
func (ua UserAccess) HasConfiguredUsers() bool {
|
||||
return ua.allUsers || len(ua.userIDMap) > 0
|
||||
}
|
||||
64
plugins/user_access_test.go
Normal file
64
plugins/user_access_test.go
Normal file
@ -0,0 +1,64 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("UserAccess", func() {
|
||||
Describe("IsAllowed", func() {
|
||||
It("returns true when allUsers is true", func() {
|
||||
ua := NewUserAccess(true, nil)
|
||||
Expect(ua.IsAllowed("any-user")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true when allUsers is true even with an explicit list", func() {
|
||||
ua := NewUserAccess(true, []string{"user-1"})
|
||||
Expect(ua.IsAllowed("other-user")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false when userIDs is empty", func() {
|
||||
ua := NewUserAccess(false, []string{})
|
||||
Expect(ua.IsAllowed("user-1")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false when userIDs is nil", func() {
|
||||
ua := NewUserAccess(false, nil)
|
||||
Expect(ua.IsAllowed("user-1")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns true when user is in the list", func() {
|
||||
ua := NewUserAccess(false, []string{"user-1", "user-2"})
|
||||
Expect(ua.IsAllowed("user-1")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false when user is not in the list", func() {
|
||||
ua := NewUserAccess(false, []string{"user-1", "user-2"})
|
||||
Expect(ua.IsAllowed("user-3")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("HasConfiguredUsers", func() {
|
||||
It("returns true when allUsers is true", func() {
|
||||
ua := NewUserAccess(true, nil)
|
||||
Expect(ua.HasConfiguredUsers()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true when specific users are configured", func() {
|
||||
ua := NewUserAccess(false, []string{"user-1"})
|
||||
Expect(ua.HasConfiguredUsers()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false when no users are configured", func() {
|
||||
ua := NewUserAccess(false, nil)
|
||||
Expect(ua.HasConfiguredUsers()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false when user list is empty", func() {
|
||||
ua := NewUserAccess(false, []string{})
|
||||
Expect(ua.HasConfiguredUsers()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user