yao/openapi/tests/oauth/acl/enforce_test.go
Max 0e260ffb6a Enhance OAuth guard with authorized info handling and ACL integration
- 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.
2025-10-20 08:50:20 +08:00

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)
})
}