- Updated the OAuth guard to set authorized information in the context using the new authorized package. - Refactored the GetAuthorizedInfo function to utilize the authorized.GetInfo method, improving clarity and maintainability. - Enhanced the ACL implementation by adding scope resolution logic in the Enforce method, ensuring proper access control based on user roles and scopes. - Improved error handling and logging during ACL operations, providing better insights into access decisions.
428 lines
11 KiB
Go
428 lines
11 KiB
Go
package acl_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/yaoapp/yao/openapi/oauth/acl"
|
|
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
|
)
|
|
|
|
// setupGinContext creates a test gin context with authorized info
|
|
func setupGinContext(method, path string, scopes []string) (*gin.Context, *httptest.ResponseRecorder) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
|
|
// Create test request
|
|
req, _ := http.NewRequest(method, path, nil)
|
|
c.Request = req
|
|
|
|
// Set authorized info in context
|
|
authInfo := &types.AuthorizedInfo{
|
|
Subject: "test-subject",
|
|
ClientID: "test-client",
|
|
UserID: "test-user",
|
|
Scope: joinScopes(scopes),
|
|
}
|
|
|
|
// Simulate what authorized.SetInfo would do
|
|
c.Set("__subject", authInfo.Subject)
|
|
c.Set("__client_id", authInfo.ClientID)
|
|
c.Set("__user_id", authInfo.UserID)
|
|
c.Set("__scope", authInfo.Scope)
|
|
|
|
return c, w
|
|
}
|
|
|
|
// joinScopes joins scopes array into space-separated string
|
|
func joinScopes(scopes []string) string {
|
|
if len(scopes) == 0 {
|
|
return ""
|
|
}
|
|
result := scopes[0]
|
|
for i := 1; i < len(scopes); i++ {
|
|
result += " " + scopes[i]
|
|
}
|
|
return result
|
|
}
|
|
|
|
// TestEnforce tests the Enforce method
|
|
func TestEnforce(t *testing.T) {
|
|
t.Run("EnforceWithDisabledACL", func(t *testing.T) {
|
|
// Create disabled ACL
|
|
config := &acl.Config{
|
|
Enabled: false,
|
|
}
|
|
|
|
aclEnforcer, err := acl.New(config)
|
|
assert.NoError(t, err)
|
|
|
|
// Setup test context
|
|
c, _ := setupGinContext("GET", "/test/endpoint", []string{"read:test"})
|
|
|
|
// Enforce should allow access when ACL is disabled
|
|
allowed, err := aclEnforcer.Enforce(c)
|
|
assert.NoError(t, err)
|
|
assert.True(t, allowed, "Should allow access when ACL is disabled")
|
|
|
|
t.Log("Disabled ACL correctly allows all access")
|
|
})
|
|
|
|
t.Run("EnforceWithEnabledACLNoScope", func(t *testing.T) {
|
|
// Create enabled ACL
|
|
config := &acl.Config{
|
|
Enabled: true,
|
|
}
|
|
|
|
aclEnforcer, err := acl.New(config)
|
|
|
|
// May fail if scopes directory doesn't exist
|
|
if err != nil {
|
|
t.Skipf("Skipping test: ACL initialization failed (expected if scopes directory missing): %v", err)
|
|
return
|
|
}
|
|
|
|
// Setup test context with no scopes
|
|
c, w := setupGinContext("GET", "/test/endpoint", []string{})
|
|
|
|
// Enforce should check permissions
|
|
allowed, err := aclEnforcer.Enforce(c)
|
|
assert.NoError(t, err)
|
|
|
|
t.Logf("Access decision: allowed=%v, status=%d", allowed, w.Code)
|
|
})
|
|
|
|
t.Run("EnforceWithScopes", func(t *testing.T) {
|
|
// Create enabled ACL
|
|
config := &acl.Config{
|
|
Enabled: true,
|
|
}
|
|
|
|
aclEnforcer, err := acl.New(config)
|
|
|
|
if err != nil {
|
|
t.Skipf("Skipping test: ACL initialization failed: %v", err)
|
|
return
|
|
}
|
|
|
|
// Setup test context with scopes
|
|
c, w := setupGinContext("GET", "/api/users", []string{"read:users", "write:users"})
|
|
|
|
// Enforce should check permissions
|
|
allowed, err := aclEnforcer.Enforce(c)
|
|
assert.NoError(t, err)
|
|
|
|
t.Logf("Access with scopes: allowed=%v, status=%d", allowed, w.Code)
|
|
|
|
if !allowed && w.Code == 403 {
|
|
t.Log("Access correctly denied with 403 response")
|
|
}
|
|
})
|
|
|
|
t.Run("EnforceChecksContext", func(t *testing.T) {
|
|
// Test that Enforce extracts info from context correctly
|
|
config := &acl.Config{
|
|
Enabled: true,
|
|
}
|
|
|
|
aclEnforcer, err := acl.New(config)
|
|
|
|
if err != nil {
|
|
t.Skipf("Skipping test: ACL initialization failed: %v", err)
|
|
return
|
|
}
|
|
|
|
// Setup context with specific authorized info
|
|
c, _ := setupGinContext("POST", "/kb/collections", []string{
|
|
"collections:create",
|
|
"collections:read",
|
|
})
|
|
|
|
// Verify authorized info can be extracted
|
|
authInfo := authorized.GetInfo(c)
|
|
assert.NotNil(t, authInfo)
|
|
assert.Equal(t, "test-user", authInfo.UserID)
|
|
assert.Equal(t, "test-client", authInfo.ClientID)
|
|
assert.Contains(t, authInfo.Scope, "collections:create")
|
|
|
|
// Enforce
|
|
allowed, err := aclEnforcer.Enforce(c)
|
|
assert.NoError(t, err)
|
|
|
|
t.Logf("Enforce with collections scopes: allowed=%v", allowed)
|
|
})
|
|
}
|
|
|
|
// TestEnforceResponseFormat tests the response format when access is denied
|
|
func TestEnforceResponseFormat(t *testing.T) {
|
|
t.Run("DeniedAccessResponse", func(t *testing.T) {
|
|
config := &acl.Config{
|
|
Enabled: true,
|
|
}
|
|
|
|
aclEnforcer, err := acl.New(config)
|
|
|
|
if err != nil {
|
|
t.Skipf("Skipping test: ACL initialization failed: %v", err)
|
|
return
|
|
}
|
|
|
|
// Setup context with insufficient scopes for a protected endpoint
|
|
c, w := setupGinContext("POST", "/protected/admin", []string{"read:basic"})
|
|
|
|
// Enforce
|
|
allowed, err := aclEnforcer.Enforce(c)
|
|
assert.NoError(t, err)
|
|
|
|
// If access is denied, check response format
|
|
if !allowed {
|
|
assert.Equal(t, 403, w.Code, "Should return 403 Forbidden")
|
|
|
|
// Response should be JSON
|
|
contentType := w.Header().Get("Content-Type")
|
|
assert.Contains(t, contentType, "application/json")
|
|
|
|
// Response body should contain error details
|
|
body := w.Body.String()
|
|
assert.Contains(t, body, "Access denied")
|
|
|
|
t.Logf("Denied access response format correct: %s", body)
|
|
} else {
|
|
t.Log("Access was allowed (no scope configuration for this endpoint)")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestGetScopes tests the internal getScopes function behavior
|
|
func TestGetScopes(t *testing.T) {
|
|
t.Run("ExtractScopesFromContext", func(t *testing.T) {
|
|
// Setup context with scopes
|
|
c, _ := setupGinContext("GET", "/test", []string{
|
|
"scope1",
|
|
"scope2",
|
|
"scope3",
|
|
})
|
|
|
|
// Get authorized info (which getScopes would use)
|
|
authInfo := authorized.GetInfo(c)
|
|
assert.NotNil(t, authInfo)
|
|
|
|
// Verify scope string contains all scopes
|
|
assert.Contains(t, authInfo.Scope, "scope1")
|
|
assert.Contains(t, authInfo.Scope, "scope2")
|
|
assert.Contains(t, authInfo.Scope, "scope3")
|
|
|
|
t.Logf("Scope string: %s", authInfo.Scope)
|
|
})
|
|
|
|
t.Run("EmptyScopes", func(t *testing.T) {
|
|
// Setup context with no scopes
|
|
c, _ := setupGinContext("GET", "/test", []string{})
|
|
|
|
authInfo := authorized.GetInfo(c)
|
|
assert.NotNil(t, authInfo)
|
|
assert.Empty(t, authInfo.Scope)
|
|
|
|
t.Log("Empty scopes handled correctly")
|
|
})
|
|
|
|
t.Run("SingleScope", func(t *testing.T) {
|
|
// Setup context with single scope
|
|
c, _ := setupGinContext("GET", "/test", []string{"single:scope"})
|
|
|
|
authInfo := authorized.GetInfo(c)
|
|
assert.NotNil(t, authInfo)
|
|
assert.Equal(t, "single:scope", authInfo.Scope)
|
|
|
|
t.Log("Single scope handled correctly")
|
|
})
|
|
}
|
|
|
|
// TestEnforceIntegration tests the complete enforcement flow
|
|
func TestEnforceIntegration(t *testing.T) {
|
|
t.Run("CompleteFlow", func(t *testing.T) {
|
|
// Create enabled ACL
|
|
config := &acl.Config{
|
|
Enabled: true,
|
|
}
|
|
|
|
aclEnforcer, err := acl.New(config)
|
|
|
|
if err != nil {
|
|
t.Skipf("Skipping integration test: ACL initialization failed: %v", err)
|
|
return
|
|
}
|
|
|
|
// Test cases with real endpoints from yao-dev-app scopes configuration
|
|
testCases := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
scopes []string
|
|
expected string // "allow" or "deny" or "unknown"
|
|
}{
|
|
{
|
|
name: "PublicEndpoint",
|
|
method: "GET",
|
|
path: "/user/entry",
|
|
scopes: []string{},
|
|
expected: "allow", // Public endpoint from scopes.yml
|
|
},
|
|
{
|
|
name: "PublicCaptcha",
|
|
method: "GET",
|
|
path: "/user/entry/captcha",
|
|
scopes: []string{},
|
|
expected: "allow", // Public endpoint
|
|
},
|
|
{
|
|
name: "KBReadWithScope",
|
|
method: "GET",
|
|
path: "/kb/collections",
|
|
scopes: []string{"collections:read:all"},
|
|
expected: "allow", // Should be allowed with scope
|
|
},
|
|
{
|
|
name: "KBWriteWithoutScope",
|
|
method: "POST",
|
|
path: "/kb/collections",
|
|
scopes: []string{"collections:read:all"},
|
|
expected: "deny", // POST requires write scope
|
|
},
|
|
{
|
|
name: "KBWriteWithScope",
|
|
method: "POST",
|
|
path: "/kb/collections",
|
|
scopes: []string{"collections:write:all"},
|
|
expected: "allow", // Should be allowed with write scope
|
|
},
|
|
{
|
|
name: "ProfileReadWithScope",
|
|
method: "GET",
|
|
path: "/user/profile",
|
|
scopes: []string{"profile:read:own"},
|
|
expected: "allow", // Should be allowed
|
|
},
|
|
{
|
|
name: "WildcardAllowedRead",
|
|
method: "GET",
|
|
path: "/kb/documents/doc-123",
|
|
scopes: []string{},
|
|
expected: "allow", // GET /kb/* allow from scopes.yml
|
|
},
|
|
{
|
|
name: "UnmatchedEndpoint",
|
|
method: "GET",
|
|
path: "/unmatched/endpoint",
|
|
scopes: []string{"some:scope"},
|
|
expected: "deny", // Default policy is deny
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
c, w := setupGinContext(tc.method, tc.path, tc.scopes)
|
|
|
|
allowed, err := aclEnforcer.Enforce(c)
|
|
assert.NoError(t, err)
|
|
|
|
t.Logf("%s %s with scopes %v: allowed=%v, status=%d",
|
|
tc.method, tc.path, tc.scopes, allowed, w.Code)
|
|
|
|
// Verify response is properly formatted
|
|
if !allowed {
|
|
assert.Equal(t, 403, w.Code)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestEnforcerInterface tests that ACL implements the Enforcer interface
|
|
func TestEnforcerInterface(t *testing.T) {
|
|
t.Run("ImplementsInterface", func(t *testing.T) {
|
|
config := &acl.Config{
|
|
Enabled: false,
|
|
}
|
|
|
|
enforcer, err := acl.New(config)
|
|
assert.NoError(t, err)
|
|
|
|
// Should implement Enforcer interface methods
|
|
assert.Implements(t, (*acl.Enforcer)(nil), enforcer)
|
|
|
|
t.Log("ACL correctly implements Enforcer interface")
|
|
})
|
|
}
|
|
|
|
// TestEnforceEdgeCases tests edge cases in enforcement
|
|
func TestEnforceEdgeCases(t *testing.T) {
|
|
t.Run("NilContext", func(t *testing.T) {
|
|
config := &acl.Config{
|
|
Enabled: false,
|
|
}
|
|
|
|
aclEnforcer, err := acl.New(config)
|
|
assert.NoError(t, err)
|
|
|
|
// Disabled ACL should handle nil context gracefully
|
|
// (though this shouldn't happen in practice)
|
|
c, _ := setupGinContext("GET", "/test", []string{})
|
|
c.Request = nil // Simulate edge case
|
|
|
|
// Should not panic
|
|
assert.NotPanics(t, func() {
|
|
// Disabled ACL returns early, so won't access c.Request
|
|
_, _ = aclEnforcer.Enforce(c)
|
|
})
|
|
})
|
|
|
|
t.Run("SpecialCharactersInPath", func(t *testing.T) {
|
|
config := &acl.Config{
|
|
Enabled: true,
|
|
}
|
|
|
|
aclEnforcer, err := acl.New(config)
|
|
|
|
if err != nil {
|
|
t.Skipf("Skipping test: %v", err)
|
|
return
|
|
}
|
|
|
|
// Test with special characters in path
|
|
c, _ := setupGinContext("GET", "/api/users/%20with%20spaces", []string{"read:users"})
|
|
|
|
allowed, err := aclEnforcer.Enforce(c)
|
|
assert.NoError(t, err)
|
|
|
|
t.Logf("Path with special chars: allowed=%v", allowed)
|
|
})
|
|
|
|
t.Run("VeryLongScope", func(t *testing.T) {
|
|
config := &acl.Config{
|
|
Enabled: true,
|
|
}
|
|
|
|
aclEnforcer, err := acl.New(config)
|
|
|
|
if err != nil {
|
|
t.Skipf("Skipping test: %v", err)
|
|
return
|
|
}
|
|
|
|
// Test with very long scope name
|
|
longScope := "very:long:scope:name:with:many:segments:to:test:handling:of:long:strings"
|
|
c, _ := setupGinContext("GET", "/test", []string{longScope})
|
|
|
|
allowed, err := aclEnforcer.Enforce(c)
|
|
assert.NoError(t, err)
|
|
|
|
t.Logf("Long scope handling: allowed=%v", allowed)
|
|
})
|
|
}
|