package acl_test import ( "context" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/yaoapp/yao/openapi/oauth" "github.com/yaoapp/yao/openapi/oauth/acl" "github.com/yaoapp/yao/openapi/oauth/authorized" "github.com/yaoapp/yao/openapi/oauth/types" "github.com/yaoapp/yao/openapi/tests/testutils" ) // 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) { testutils.Prepare(t) defer testutils.Clean() // 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, _ := setupGinContext("GET", "/test/endpoint", []string{}) // Enforce should deny access and return error (no scope for unmatched endpoint) allowed, err := aclEnforcer.Enforce(c) assert.False(t, allowed, "Should deny access for unmatched endpoint") assert.Error(t, err, "Should return error when access is denied") if err != nil { t.Logf("Access correctly denied: %v", err) } }) t.Run("EnforceWithScopes", func(t *testing.T) { testutils.Prepare(t) defer testutils.Clean() // 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 for unmatched endpoint c, _ := setupGinContext("GET", "/api/users", []string{"read:users", "write:users"}) // Enforce should deny (unmatched endpoint, default policy: deny) allowed, err := aclEnforcer.Enforce(c) assert.False(t, allowed, "Should deny access for unmatched endpoint") assert.Error(t, err, "Should return error when access is denied") t.Logf("Access correctly denied: %v", err) }) t.Run("EnforceChecksContext", func(t *testing.T) { testutils.Prepare(t) defer testutils.Clean() // 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 - should deny because required scope is "collections:write:all" allowed, err := aclEnforcer.Enforce(c) assert.False(t, allowed, "Should deny access without required scope") assert.Error(t, err, "Should return error for missing required scopes") t.Logf("Access correctly denied: %v", err) }) t.Run("EnforceUpdatesConstraints", func(t *testing.T) { testutils.Prepare(t) defer testutils.Clean() // Get user provider and set up test data ctx := context.Background() provider, err := oauth.OAuth.GetUserProvider() if err != nil || provider == nil { t.Skip("Skipping: user provider not available") return } // Set up test data testData := setupACLTestData(t, ctx, provider) defer cleanupACLTestData(t, ctx, provider, testData) // Use global ACL instance aclEnforcer := acl.Global if aclEnforcer == nil || !aclEnforcer.Enabled() { t.Skip("Skipping: ACL not enabled") return } // Test case 1: OwnerOnly constraint (profile:read:own has owner: true) t.Run("OwnerOnlyConstraint", func(t *testing.T) { c, _ := setupGinContext("GET", "/user/profile", []string{"profile:read:own"}) allowed, err := aclEnforcer.Enforce(c) assert.NoError(t, err, "Should not return error") assert.True(t, allowed, "Should allow access with profile:read:own") // Get updated authorized info from context authInfo := authorized.GetInfo(c) assert.NotNil(t, authInfo, "AuthInfo should not be nil") // Verify OwnerOnly constraint was set assert.True(t, authInfo.Constraints.OwnerOnly, "OwnerOnly should be true for profile:read:own endpoint") assert.False(t, authInfo.Constraints.TeamOnly, "TeamOnly should be false for profile endpoint") t.Logf("✓ OwnerOnly constraint correctly set: OwnerOnly=%v, TeamOnly=%v", authInfo.Constraints.OwnerOnly, authInfo.Constraints.TeamOnly) }) // Test case 2: TeamOnly constraint (collections:read:team has team: true) t.Run("TeamOnlyConstraint", func(t *testing.T) { c, _ := setupGinContext("GET", "/kb/collections/team", []string{"collections:read:team"}) allowed, err := aclEnforcer.Enforce(c) assert.NoError(t, err, "Should not return error") assert.True(t, allowed, "Should allow access with collections:read:team") // Get updated authorized info from context authInfo := authorized.GetInfo(c) assert.NotNil(t, authInfo, "AuthInfo should not be nil") // Verify TeamOnly constraint was set assert.True(t, authInfo.Constraints.TeamOnly, "TeamOnly should be true for collections:read:team endpoint") assert.False(t, authInfo.Constraints.OwnerOnly, "OwnerOnly should be false for team endpoint") t.Logf("✓ TeamOnly constraint correctly set: OwnerOnly=%v, TeamOnly=%v", authInfo.Constraints.OwnerOnly, authInfo.Constraints.TeamOnly) }) // Test case 3: No constraints (collections:read:all has no owner/team flags) t.Run("NoConstraints", func(t *testing.T) { c, _ := setupGinContext("GET", "/kb/collections", []string{"collections:read:all"}) allowed, err := aclEnforcer.Enforce(c) assert.NoError(t, err, "Should not return error") assert.True(t, allowed, "Should allow access with collections:read:all") // Get updated authorized info from context authInfo := authorized.GetInfo(c) assert.NotNil(t, authInfo, "AuthInfo should not be nil") // Verify no constraints were set assert.False(t, authInfo.Constraints.OwnerOnly, "OwnerOnly should be false for unrestricted endpoint") assert.False(t, authInfo.Constraints.TeamOnly, "TeamOnly should be false for unrestricted endpoint") t.Logf("✓ No constraints for unrestricted endpoint: OwnerOnly=%v, TeamOnly=%v", authInfo.Constraints.OwnerOnly, authInfo.Constraints.TeamOnly) }) // Test case 4: Both constraints (if such endpoint exists) t.Run("BothOwnerAndTeamConstraints", func(t *testing.T) { c, _ := setupGinContext("GET", "/kb/collections/own", []string{"collections:read:own"}) allowed, err := aclEnforcer.Enforce(c) assert.NoError(t, err, "Should not return error") assert.True(t, allowed, "Should allow access with collections:read:own") // Get updated authorized info from context authInfo := authorized.GetInfo(c) assert.NotNil(t, authInfo, "AuthInfo should not be nil") // Verify OwnerOnly constraint was set (collections:read:own has owner: true) assert.True(t, authInfo.Constraints.OwnerOnly, "OwnerOnly should be true for collections:read:own endpoint") t.Logf("✓ Owner constraint for own collections: OwnerOnly=%v, TeamOnly=%v", authInfo.Constraints.OwnerOnly, authInfo.Constraints.TeamOnly) }) }) } // TestEnforceReturnValues tests Enforce return values when access is denied // Note: HTTP response format is handled by Guard middleware, not by Enforce func TestEnforceReturnValues(t *testing.T) { t.Run("DeniedAccessReturnValues", func(t *testing.T) { testutils.Prepare(t) defer testutils.Clean() 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 scopes for an unmatched endpoint c, _ := setupGinContext("POST", "/protected/admin", []string{"read:basic"}) // Enforce should return false and an error allowed, err := aclEnforcer.Enforce(c) assert.False(t, allowed, "Should deny access") assert.Error(t, err, "Should return error when access is denied") // Verify error contains ACL error information if err != nil { aclErr, ok := err.(*acl.Error) assert.True(t, ok, "Error should be ACL Error type") if aclErr != nil { assert.NotEmpty(t, aclErr.Message, "Error should have message") t.Logf("Access correctly denied with error: %v", aclErr.Message) } } }) } // 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") }) } // setupACLTestRoles creates test roles with permissions for ACL testing func setupACLTestRoles(t *testing.T, ctx context.Context, provider types.UserProvider) { roles := []struct { roleID string name string description string permissions []string restricted []string }{ { roleID: "system:root", name: "System Root", description: "System root role with full access", permissions: []string{"*:*:*"}, restricted: []string{}, }, { roleID: "acl_test_user", name: "ACL Test User Role", description: "Role for ACL user testing", permissions: []string{ "profile:read:own", "profile:write:own", "collections:read:all", "collections:write:all", }, restricted: []string{}, }, { roleID: "acl_test_team", name: "ACL Test Team Role", description: "Role for ACL team testing", permissions: []string{ "team:read:all", "team:write:all", "collections:read:team", }, restricted: []string{}, }, { roleID: "acl_test_member", name: "ACL Test Member Role", description: "Role for ACL member testing", permissions: []string{ "member:read:own", "member:write:own", "collections:read:own", }, restricted: []string{ "admin:access", }, }, } for _, role := range roles { // Create role roleData := map[string]interface{}{ "role_id": role.roleID, "name": role.name, "description": role.description, "status": "active", } _, err := provider.CreateRole(ctx, roleData) if err != nil { t.Logf("Warning: Failed to create role %s (may already exist): %v", role.roleID, err) } // Set role permissions permissions := map[string]interface{}{ "permissions": role.permissions, "restricted_permissions": role.restricted, } err = provider.SetRolePermissions(ctx, role.roleID, permissions) if err != nil { t.Logf("Warning: Failed to set permissions for role %s: %v", role.roleID, err) } } t.Log("Set up ACL test roles and permissions") } // cleanupACLTestRoles removes test roles created for ACL testing func cleanupACLTestRoles(t *testing.T, ctx context.Context, provider types.UserProvider) { roles := []string{"system:root", "acl_test_user", "acl_test_team", "acl_test_member"} for _, roleID := range roles { err := provider.DeleteRole(ctx, roleID) if err != nil { t.Logf("Warning: Failed to delete test role %s: %v", roleID, err) } } t.Log("Cleaned up ACL test roles") } // setupACLTestData creates test users, teams, members and roles for ACL testing func setupACLTestData(t *testing.T, ctx context.Context, provider types.UserProvider) *ACLTestData { data := &ACLTestData{ UserIDs: make([]string, 0), TeamIDs: make([]string, 0), MemberIDs: make([]string, 0), } // Set up roles first setupACLTestRoles(t, ctx, provider) // Create test users with different roles users := []struct { userID string email string username string roleID string }{ {"test-user", "testuser@acl.test", "testuser", "system:root"}, // For test-client in setupGinContext {"acl-user-1", "acluser1@acl.test", "acluser1", "acl_test_user"}, // Regular user {"acl-owner", "aclowner@acl.test", "aclowner", "acl_test_user"}, // Team owner {"acl-member-1", "aclmember1@acl.test", "aclmember1", "acl_test_user"}, // Team member } for _, u := range users { userData := map[string]interface{}{ "user_id": u.userID, "email": u.email, "preferred_username": u.username, "password_hash": "test_hash", "status": "active", "role_id": u.roleID, } userID, err := provider.CreateUser(ctx, userData) if err != nil { t.Logf("Warning: Failed to create user %s: %v", u.userID, err) } else { data.UserIDs = append(data.UserIDs, userID) } } // Create test team teamData := map[string]interface{}{ "name": "ACL Test Team", "description": "Team for ACL testing", "owner_id": "acl-owner", "status": "active", "role_id": "acl_test_team", } teamID, err := provider.CreateTeam(ctx, teamData) if err != nil { t.Logf("Warning: Failed to create team: %v", err) } else { data.TeamIDs = append(data.TeamIDs, teamID) // Create team members members := []struct { userID string roleID string }{ {"acl-owner", "acl_test_member"}, // Owner as member {"acl-member-1", "acl_test_member"}, // Regular member } for _, m := range members { memberData := map[string]interface{}{ "team_id": teamID, "user_id": m.userID, "role_id": m.roleID, "member_type": "user", "status": "active", } memberID, err := provider.CreateMember(ctx, memberData) if err != nil { t.Logf("Warning: Failed to create member %s: %v", m.userID, err) } else { data.MemberIDs = append(data.MemberIDs, memberID) } } } t.Logf("Created ACL test data: %d users, %d teams, %d members", len(data.UserIDs), len(data.TeamIDs), len(data.MemberIDs)) return data } // cleanupACLTestData removes all test data created for ACL testing func cleanupACLTestData(t *testing.T, ctx context.Context, provider types.UserProvider, data *ACLTestData) { if data == nil { return } // Remove teams (this will cascade remove members) for _, teamID := range data.TeamIDs { err := provider.DeleteTeam(ctx, teamID) if err != nil { t.Logf("Warning: Failed to delete test team %s: %v", teamID, err) } } // Remove users for _, userID := range data.UserIDs { err := provider.DeleteUser(ctx, userID) if err != nil { t.Logf("Warning: Failed to delete test user %s: %v", userID, err) } } // Remove roles cleanupACLTestRoles(t, ctx, provider) t.Logf("Cleaned up ACL test data: %d users, %d teams", len(data.UserIDs), len(data.TeamIDs)) } // ACLTestData holds test data created for ACL testing type ACLTestData struct { UserIDs []string TeamIDs []string MemberIDs []string } // TestEnforceIntegration tests the complete enforcement flow // Note: This test only validates scope-based ACL when RoleManager is not configured // For full role-based testing, see role package tests func TestEnforceIntegration(t *testing.T) { testutils.Prepare(t) defer testutils.Clean() t.Run("ScopeBasedFlow", func(t *testing.T) { // Use the global ACL instance created by testutils.Prepare // This tests the real configuration loaded from the application aclEnforcer := acl.Global if aclEnforcer == nil || !aclEnforcer.Enabled() { t.Skip("Skipping integration test: ACL is not enabled") return } // Get user provider and set up complete test data ctx := context.Background() provider, err := oauth.OAuth.GetUserProvider() if err != nil || provider == nil { t.Skip("Skipping: user provider not available") return } // Set up test data and ensure cleanup testData := setupACLTestData(t, ctx, provider) defer cleanupACLTestData(t, ctx, provider, testData) // 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" }{ { 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/some-other-resource/item-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, _ := setupGinContext(tc.method, tc.path, tc.scopes) allowed, err := aclEnforcer.Enforce(c) t.Logf("%s %s with scopes %v: allowed=%v", tc.method, tc.path, tc.scopes, allowed) // Verify Enforce return values if tc.expected == "allow" { assert.True(t, allowed, "Should allow access") assert.NoError(t, err, "Should not return error when access is allowed") } else { assert.False(t, allowed, "Should deny access") assert.Error(t, err, "Should return error when access is denied") } }) } }) } // 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) { testutils.Prepare(t) defer testutils.Clean() 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 (unmatched endpoint) c, _ := setupGinContext("GET", "/api/users/%20with%20spaces", []string{"read:users"}) allowed, err := aclEnforcer.Enforce(c) assert.False(t, allowed, "Should deny unmatched endpoint") assert.Error(t, err, "Should return error for unmatched endpoint") t.Logf("Path with special chars correctly denied: %v", err) }) t.Run("VeryLongScope", func(t *testing.T) { testutils.Prepare(t) defer testutils.Clean() 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 (unmatched endpoint) 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.False(t, allowed, "Should deny unmatched endpoint") assert.Error(t, err, "Should return error for unmatched endpoint") t.Logf("Long scope correctly handled: %v", err) }) }