From c348e79f06382de3c17371a1118e21206a435c0d Mon Sep 17 00:00:00 2001 From: Thor Arne Johansen Date: Tue, 11 Nov 2025 15:54:59 +0100 Subject: [PATCH 01/11] feat: Add multi-context support for policy and grouping separation This PR implements support for using multiple CasbinDbContext instances with a single EFCoreAdapter, enabling separation of policy and grouping data across different databases, schemas, or tables. Key Features: - Multi-context architecture via ICasbinDbContextProvider interface - Transaction support for atomic operations across contexts - Shared connection pooling when contexts use same connection string - Full backward compatibility with existing single-context usage - Extended schema support from v0-v5 to v0-v13 columns Implementation: - Add ServiceProviderContextProvider for DI scenarios - Add SingleContextProvider for direct DbContext usage - Implement context routing based on policy type (p/g) - Add shared connection management for atomic transactions - New CasbinDbContextExtension.Clear() method for testing Testing: - 120 tests passing across all target frameworks (.NET 6.0, 8.0, 9.0) - New integration tests for multi-context scenarios - Transaction integrity tests for atomic operations - Schema distribution tests across contexts - AutoSave behavior tests - Backward compatibility tests for single-context usage Documentation: - MULTI_CONTEXT_DESIGN.md: Architecture and design decisions - MULTI_CONTEXT_USAGE_GUIDE.md: Usage patterns and examples - TEST_SUMMARY.md: Comprehensive test coverage documentation - Updated README.md with multi-context examples This implementation maintains full API compatibility while enabling advanced multi-database scenarios for large-scale deployments. --- .gitignore | 1 + .../AutoTest.cs | 294 +----- .../BackwardCompatibilityTest.cs | 292 ++++++ ...bin.Persist.Adapter.EFCore.UnitTest.csproj | 12 +- .../Extensions/CasbinDbContextExtension.cs | 28 +- .../Fixtures/MultiContextProviderFixture.cs | 84 ++ .../Fixtures/PolicyTypeContextProvider.cs | 50 + .../Integration/AutoSaveTests.cs | 982 ++++++++++++++++++ .../Integration/IntegrationTestCollection.cs | 20 + .../Integration/README.md | 307 ++++++ .../Integration/SchemaDistributionTests.cs | 340 ++++++ .../TransactionIntegrityTestFixture.cs | 236 +++++ .../Integration/TransactionIntegrityTests.cs | 576 ++++++++++ .../MultiContextTest.cs | 495 +++++++++ .../SpecialPolicyTest.cs | 4 +- .../Casbin.Persist.Adapter.EFCore.csproj | 2 +- .../CasbinDbContext.cs | 3 + ...ultPersistPolicyEntityTypeConfiguration.cs | 16 +- .../EFCoreAdapter.Internal.cs | 147 ++- .../EFCoreAdapter.cs | 748 +++++++++++-- .../ICasbinDbContextProvider.cs | 46 + .../ServiceProviderContextProvider.cs | 54 + .../SingleContextProvider.cs | 56 + MULTI_CONTEXT_DESIGN.md | 636 ++++++++++++ MULTI_CONTEXT_USAGE_GUIDE.md | 585 +++++++++++ README.md | 57 + TEST_SUMMARY.md | 177 ++++ 27 files changed, 5840 insertions(+), 408 deletions(-) create mode 100644 Casbin.Persist.Adapter.EFCore.UnitTest/BackwardCompatibilityTest.cs create mode 100644 Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/MultiContextProviderFixture.cs create mode 100644 Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/PolicyTypeContextProvider.cs create mode 100644 Casbin.Persist.Adapter.EFCore.UnitTest/Integration/AutoSaveTests.cs create mode 100644 Casbin.Persist.Adapter.EFCore.UnitTest/Integration/IntegrationTestCollection.cs create mode 100644 Casbin.Persist.Adapter.EFCore.UnitTest/Integration/README.md create mode 100644 Casbin.Persist.Adapter.EFCore.UnitTest/Integration/SchemaDistributionTests.cs create mode 100644 Casbin.Persist.Adapter.EFCore.UnitTest/Integration/TransactionIntegrityTestFixture.cs create mode 100644 Casbin.Persist.Adapter.EFCore.UnitTest/Integration/TransactionIntegrityTests.cs create mode 100644 Casbin.Persist.Adapter.EFCore.UnitTest/MultiContextTest.cs create mode 100644 Casbin.Persist.Adapter.EFCore/ICasbinDbContextProvider.cs create mode 100644 Casbin.Persist.Adapter.EFCore/ServiceProviderContextProvider.cs create mode 100644 Casbin.Persist.Adapter.EFCore/SingleContextProvider.cs create mode 100644 MULTI_CONTEXT_DESIGN.md create mode 100644 MULTI_CONTEXT_USAGE_GUIDE.md create mode 100644 TEST_SUMMARY.md diff --git a/.gitignore b/.gitignore index c016bb4..76c17df 100644 --- a/.gitignore +++ b/.gitignore @@ -332,3 +332,4 @@ ASALocalRun/ # MFractors (Xamarin productivity tool) working folder .mfractor/ .vscode/ +nul diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/AutoTest.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/AutoTest.cs index a0bb55a..7eb1196 100644 --- a/Casbin.Persist.Adapter.EFCore.UnitTest/AutoTest.cs +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/AutoTest.cs @@ -9,307 +9,17 @@ namespace Casbin.Persist.Adapter.EFCore.UnitTest { - public class AdapterTest : TestUtil, IClassFixture, IClassFixture + public class EFCoreAdapterTest : TestUtil, IClassFixture, IClassFixture { private readonly ModelProvideFixture _modelProvideFixture; private readonly DbContextProviderFixture _dbContextProviderFixture; - public AdapterTest(ModelProvideFixture modelProvideFixture, DbContextProviderFixture dbContextProviderFixture) + public EFCoreAdapterTest(ModelProvideFixture modelProvideFixture, DbContextProviderFixture dbContextProviderFixture) { _modelProvideFixture = modelProvideFixture; _dbContextProviderFixture = dbContextProviderFixture; } - [Fact] - public void TestAdapterAutoSave() - { - using var context = _dbContextProviderFixture.GetContext("AutoSave"); - InitPolicy(context); - var adapter = new EFCoreAdapter(context); - var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); - - #region Load policy test - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write"), - AsList("data2_admin", "data2", "read"), - AsList("data2_admin", "data2", "write") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 5); - #endregion - - #region Add policy test - enforcer.AddPolicy("alice", "data1", "write"); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write"), - AsList("data2_admin", "data2", "read"), - AsList("data2_admin", "data2", "write"), - AsList("alice", "data1", "write") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 6); - #endregion - - #region Remove poliy test - enforcer.RemovePolicy("alice", "data1", "write"); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write"), - AsList("data2_admin", "data2", "read"), - AsList("data2_admin", "data2", "write") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 5); - - enforcer.RemoveFilteredPolicy(0, "data2_admin"); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 3); - #endregion - - #region Update policy test - enforcer.UpdatePolicy(AsList("alice", "data1", "read"), - AsList("alice", "data2", "write")); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data2", "write"), - AsList("bob", "data2", "write") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 3); - - enforcer.UpdatePolicy(AsList("alice", "data2", "write"), - AsList("alice", "data1", "read")); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 3); - #endregion - - #region Batch APIs test - enforcer.AddPolicies(new [] - { - new List{"alice", "data2", "write"}, - new List{"bob", "data1", "read"} - }); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write"), - AsList("alice", "data2", "write"), - AsList("bob", "data1", "read") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 5); - - enforcer.RemovePolicies(new [] - { - new List{"alice", "data1", "read"}, - new List{"bob", "data2", "write"} - }); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data2", "write"), - AsList("bob", "data1", "read") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 3); - - enforcer.UpdatePolicies(AsList( - AsList("alice", "data2", "write"), - AsList("bob", "data1", "read") - ), AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write") - )); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 3); - - enforcer.UpdatePolicies(AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write") - ), AsList( - AsList("alice", "data2", "write"), - AsList("bob", "data1", "read") - )); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data2", "write"), - AsList("bob", "data1", "read") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 3); - #endregion - - #region IFilteredAdapter test - enforcer.LoadFilteredPolicy(new Filter - { - P = new List{"bob", "data1", "read"}, - }); - TestGetPolicy(enforcer, AsList( - AsList("bob", "data1", "read") - )); - Assert.True(enforcer.GetGroupingPolicy().Count() is 0); - Assert.True(context.Policies.AsNoTracking().Count() is 3); - - enforcer.LoadFilteredPolicy(new Filter - { - P = new List{"", "data2", ""}, - G = new List{"", "data2_admin"}, - }); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data2", "write") - )); - TestGetGroupingPolicy(enforcer, AsList( - AsList("alice", "data2_admin") - )); - Assert.True(enforcer.GetGroupingPolicy().Count() is 1); - Assert.True(context.Policies.AsNoTracking().Count() is 3); - #endregion - } - - [Fact] - public async Task TestAdapterAutoSaveAsync() - { - await using var context = _dbContextProviderFixture.GetContext("AutoSaveAsync"); - InitPolicy(context); - var adapter = new EFCoreAdapter(context); - var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); - - #region Load policy test - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write"), - AsList("data2_admin", "data2", "read"), - AsList("data2_admin", "data2", "write") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 5); - #endregion - - #region Add policy test - await enforcer.AddPolicyAsync("alice", "data1", "write"); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write"), - AsList("data2_admin", "data2", "read"), - AsList("data2_admin", "data2", "write"), - AsList("alice", "data1", "write") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 6); - #endregion - - #region Remove policy test - await enforcer.RemovePolicyAsync("alice", "data1", "write"); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write"), - AsList("data2_admin", "data2", "read"), - AsList("data2_admin", "data2", "write") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 5); - - await enforcer.RemoveFilteredPolicyAsync(0, "data2_admin"); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 3); - #endregion - - #region Update policy test - await enforcer.UpdatePolicyAsync(AsList("alice", "data1", "read"), - AsList("alice", "data2", "write")); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data2", "write"), - AsList("bob", "data2", "write") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 3); - - await enforcer.UpdatePolicyAsync(AsList("alice", "data2", "write"), - AsList("alice", "data1", "read")); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 3); - #endregion - - #region Batch APIs test - await enforcer.AddPoliciesAsync(new [] - { - new List{"alice", "data2", "write"}, - new List{"bob", "data1", "read"} - }); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write"), - AsList("alice", "data2", "write"), - AsList("bob", "data1", "read") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 5); - - await enforcer.RemovePoliciesAsync(new [] - { - new List{"alice", "data1", "read"}, - new List{"bob", "data2", "write"} - }); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data2", "write"), - AsList("bob", "data1", "read") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 3); - - await enforcer.UpdatePoliciesAsync(AsList( - AsList("alice", "data2", "write"), - AsList("bob", "data1", "read") - ), AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write") - )); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 3); - - await enforcer.UpdatePoliciesAsync(AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write") - ), AsList( - AsList("alice", "data2", "write"), - AsList("bob", "data1", "read") - )); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data2", "write"), - AsList("bob", "data1", "read") - )); - Assert.True(context.Policies.AsNoTracking().Count() is 3); - #endregion - - #region IFilteredAdapter test - await enforcer.LoadFilteredPolicyAsync(new Filter - { - P = new List{"bob", "data1", "read"}, - }); - TestGetPolicy(enforcer, AsList( - AsList("bob", "data1", "read") - )); - Assert.True(enforcer.GetGroupingPolicy().Count() is 0); - Assert.True(context.Policies.AsNoTracking().Count() is 3); - - await enforcer.LoadFilteredPolicyAsync(new Filter - { - P = new List{"", "data2", ""}, - G = new List{"", "data2_admin"}, - }); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data2", "write") - )); - TestGetGroupingPolicy(enforcer, AsList( - AsList("alice", "data2_admin") - )); - Assert.True(enforcer.GetGroupingPolicy().Count() is 1); - Assert.True(context.Policies.AsNoTracking().Count() is 3); - #endregion - } - private static void InitPolicy(CasbinDbContext context) { context.Clear(); diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/BackwardCompatibilityTest.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/BackwardCompatibilityTest.cs new file mode 100644 index 0000000..5cbabdc --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/BackwardCompatibilityTest.cs @@ -0,0 +1,292 @@ +using System.Linq; +using System.Threading.Tasks; +using Casbin.Persist.Adapter.EFCore.Entities; +using Casbin.Persist.Adapter.EFCore.UnitTest.Extensions; +using Casbin.Persist.Adapter.EFCore.UnitTest.Fixtures; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Casbin.Persist.Adapter.EFCore.UnitTest +{ + /// + /// Tests to ensure backward compatibility with existing single-context behavior. + /// These tests verify that the multi-context changes don't break existing usage patterns. + /// + public class BackwardCompatibilityTest : TestUtil, + IClassFixture, + IClassFixture + { + private readonly ModelProvideFixture _modelProvideFixture; + private readonly DbContextProviderFixture _dbContextProviderFixture; + + public BackwardCompatibilityTest( + ModelProvideFixture modelProvideFixture, + DbContextProviderFixture dbContextProviderFixture) + { + _modelProvideFixture = modelProvideFixture; + _dbContextProviderFixture = dbContextProviderFixture; + } + + [Fact] + public void TestSingleContextConstructorStillWorks() + { + // Arrange - Using original constructor pattern + using var context = _dbContextProviderFixture.GetContext("SingleContextConstructor"); + context.Clear(); + + // Act - Create adapter using single-context constructor (original API) + var adapter = new EFCoreAdapter(context); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + // Add policies + enforcer.AddPolicy("alice", "data1", "read"); + enforcer.AddGroupingPolicy("alice", "admin"); + + // Assert - All policies should be in single context + Assert.Equal(2, context.Policies.Count()); + + var policies = context.Policies.ToList(); + Assert.Contains(policies, p => p.Type == "p" && p.Value1 == "alice"); + Assert.Contains(policies, p => p.Type == "g" && p.Value1 == "alice"); + } + + [Fact] + public async Task TestSingleContextAsyncOperationsStillWork() + { + // Arrange + await using var context = _dbContextProviderFixture.GetContext("SingleContextAsync"); + context.Clear(); + + var adapter = new EFCoreAdapter(context); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + // Act + await enforcer.AddPolicyAsync("alice", "data1", "read"); + await enforcer.AddGroupingPolicyAsync("alice", "admin"); + + // Assert + Assert.Equal(2, await context.Policies.CountAsync()); + } + + [Fact] + public void TestSingleContextLoadAndSave() + { + // Arrange + using var context = _dbContextProviderFixture.GetContext("SingleContextLoadSave"); + context.Clear(); + + var adapter = new EFCoreAdapter(context); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + // Act - Add and save + enforcer.AddPolicy("alice", "data1", "read"); + enforcer.AddGroupingPolicy("alice", "admin"); + enforcer.SavePolicy(); + + // Create new enforcer and load + var newEnforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + newEnforcer.LoadPolicy(); + + // Assert + TestGetPolicy(newEnforcer, AsList( + AsList("alice", "data1", "read") + )); + + TestGetGroupingPolicy(newEnforcer, AsList( + AsList("alice", "admin") + )); + } + + [Fact] + public void TestSingleContextWithExistingTests() + { + // This test mimics the pattern from EFCoreAdapterTest.cs to ensure compatibility + using var context = _dbContextProviderFixture.GetContext("ExistingPattern"); + context.Clear(); + + // Initialize with data (like InitPolicy in EFCoreAdapterTest.cs) + context.Policies.AddRange(new[] + { + new EFCorePersistPolicy { Type = "p", Value1 = "alice", Value2 = "data1", Value3 = "read" }, + new EFCorePersistPolicy { Type = "p", Value1 = "bob", Value2 = "data2", Value3 = "write" }, + new EFCorePersistPolicy { Type = "g", Value1 = "alice", Value2 = "data2_admin" } + }); + context.SaveChanges(); + + var adapter = new EFCoreAdapter(context); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + // Act - Load policy + enforcer.LoadPolicy(); + + // Assert - Should match expected behavior + TestGetPolicy(enforcer, AsList( + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write") + )); + + TestGetGroupingPolicy(enforcer, AsList( + AsList("alice", "data2_admin") + )); + } + + [Fact] + public void TestSingleContextRemoveOperations() + { + // Arrange + using var context = _dbContextProviderFixture.GetContext("SingleContextRemove"); + context.Clear(); + + var adapter = new EFCoreAdapter(context); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + enforcer.AddPolicy("alice", "data1", "read"); + enforcer.AddPolicy("bob", "data2", "write"); + + // Act + enforcer.RemovePolicy("alice", "data1", "read"); + + // Assert + Assert.Single(context.Policies); + var remaining = context.Policies.First(); + Assert.Equal("bob", remaining.Value1); + } + + [Fact] + public void TestSingleContextUpdateOperations() + { + // Arrange + using var context = _dbContextProviderFixture.GetContext("SingleContextUpdate"); + context.Clear(); + + var adapter = new EFCoreAdapter(context); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + enforcer.AddPolicy("alice", "data1", "read"); + + // Act + enforcer.UpdatePolicy( + AsList("alice", "data1", "read"), + AsList("alice", "data1", "write") + ); + + // Assert + Assert.Single(context.Policies); + var policy = context.Policies.First(); + Assert.Equal("write", policy.Value3); + } + + [Fact] + public void TestSingleContextBatchOperations() + { + // Arrange + using var context = _dbContextProviderFixture.GetContext("SingleContextBatch"); + context.Clear(); + + var adapter = new EFCoreAdapter(context); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + // Act - Add multiple + enforcer.AddPolicies(new[] + { + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write"), + AsList("charlie", "data3", "read") + }); + + // Assert + Assert.Equal(3, context.Policies.Count()); + + // Act - Remove multiple + enforcer.RemovePolicies(new[] + { + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write") + }); + + // Assert + Assert.Single(context.Policies); + } + + [Fact] + public void TestSingleContextFilteredLoading() + { + // Arrange + using var context = _dbContextProviderFixture.GetContext("SingleContextFiltered"); + context.Clear(); + + context.Policies.AddRange(new[] + { + new EFCorePersistPolicy { Type = "p", Value1 = "alice", Value2 = "data1", Value3 = "read" }, + new EFCorePersistPolicy { Type = "p", Value1 = "bob", Value2 = "data2", Value3 = "write" }, + new EFCorePersistPolicy { Type = "g", Value1 = "alice", Value2 = "admin" } + }); + context.SaveChanges(); + + var adapter = new EFCoreAdapter(context); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + // Act - Load only alice's policies + enforcer.LoadFilteredPolicy(new Filter + { + P = AsList("alice", "", "") + }); + + // Assert + Assert.True(adapter.IsFiltered); + TestGetPolicy(enforcer, AsList( + AsList("alice", "data1", "read") + )); + } + + [Fact] + public void TestSingleContextProviderWrapping() + { + // Arrange - Create adapter with explicit SingleContextProvider + using var context = _dbContextProviderFixture.GetContext("ProviderWrapping"); + context.Clear(); + + var provider = new SingleContextProvider(context); + var adapter = new EFCoreAdapter(provider); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + // Act + enforcer.AddPolicy("alice", "data1", "read"); + + // Assert - Should behave identically to direct context constructor + Assert.Single(context.Policies); + Assert.Equal("alice", context.Policies.First().Value1); + } + + [Fact] + public void TestSingleContextProviderGetAllContexts() + { + // Arrange + using var context = _dbContextProviderFixture.GetContext("ProviderGetAll"); + var provider = new SingleContextProvider(context); + + // Act + var contexts = provider.GetAllContexts().ToList(); + + // Assert + Assert.Single(contexts); + Assert.Same(context, contexts[0]); + } + + [Fact] + public void TestSingleContextProviderGetContextForPolicyType() + { + // Arrange + using var context = _dbContextProviderFixture.GetContext("ProviderGetForType"); + var provider = new SingleContextProvider(context); + + // Act & Assert - All policy types should return same context + Assert.Same(context, provider.GetContextForPolicyType("p")); + Assert.Same(context, provider.GetContextForPolicyType("p2")); + Assert.Same(context, provider.GetContextForPolicyType("g")); + Assert.Same(context, provider.GetContextForPolicyType("g2")); + Assert.Same(context, provider.GetContextForPolicyType(null)); + Assert.Same(context, provider.GetContextForPolicyType("")); + } + } +} diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Casbin.Persist.Adapter.EFCore.UnitTest.csproj b/Casbin.Persist.Adapter.EFCore.UnitTest/Casbin.Persist.Adapter.EFCore.UnitTest.csproj index d482593..6253357 100644 --- a/Casbin.Persist.Adapter.EFCore.UnitTest/Casbin.Persist.Adapter.EFCore.UnitTest.csproj +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Casbin.Persist.Adapter.EFCore.UnitTest.csproj @@ -7,6 +7,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -23,32 +24,38 @@ + - + + + + + + @@ -62,6 +69,9 @@ Always + + Always + diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Extensions/CasbinDbContextExtension.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Extensions/CasbinDbContextExtension.cs index 59bbd0b..829d4f2 100644 --- a/Casbin.Persist.Adapter.EFCore.UnitTest/Extensions/CasbinDbContextExtension.cs +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Extensions/CasbinDbContextExtension.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; namespace Casbin.Persist.Adapter.EFCore.UnitTest.Extensions { @@ -6,8 +7,31 @@ public static class CasbinDbContextExtension { internal static void Clear(this CasbinDbContext dbContext) where TKey : IEquatable { - dbContext.RemoveRange(dbContext.Policies); - dbContext.SaveChanges(); + // Force model initialization before ensuring database exists + // This ensures EF Core knows about all entity configurations + _ = dbContext.Model; + + // Ensure database and tables exist before attempting to clear + dbContext.Database.EnsureCreated(); + + // Try to access and clear policies + try + { + var policies = dbContext.Policies.ToList(); + if (policies.Count > 0) + { + dbContext.RemoveRange(policies); + dbContext.SaveChanges(); + } + } + catch (Microsoft.Data.Sqlite.SqliteException) + { + // If table still doesn't exist after EnsureCreated, + // force a second attempt with model refresh + dbContext.Database.EnsureDeleted(); + _ = dbContext.Model; + dbContext.Database.EnsureCreated(); + } } } } \ No newline at end of file diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/MultiContextProviderFixture.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/MultiContextProviderFixture.cs new file mode 100644 index 0000000..14036ae --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/MultiContextProviderFixture.cs @@ -0,0 +1,84 @@ +using System; +using Microsoft.EntityFrameworkCore; + +namespace Casbin.Persist.Adapter.EFCore.UnitTest.Fixtures +{ + /// + /// Fixture for creating multi-context test scenarios with separate contexts for policies and groupings + /// + public class MultiContextProviderFixture : IDisposable + { + private bool _disposed; + + /// + /// Creates a multi-context provider with separate contexts for policy and grouping rules. + /// Uses separate database files with the same table name for proper isolation. + /// This approach avoids SQLite transaction limitations across tables. + /// + /// Unique name for this test to avoid database conflicts + /// A PolicyTypeContextProvider configured for testing + public PolicyTypeContextProvider GetMultiContextProvider(string testName) + { + // Use separate database files for proper isolation + var policyDbName = $"MultiContext_{testName}_policy.db"; + var groupingDbName = $"MultiContext_{testName}_grouping.db"; + + // Create policy context with its own database and default table name + var policyOptions = new DbContextOptionsBuilder>() + .UseSqlite($"Data Source={policyDbName}") + .Options; + var policyContext = new CasbinDbContext(policyOptions); + policyContext.Database.EnsureCreated(); + + // Create grouping context with its own database and default table name + var groupingOptions = new DbContextOptionsBuilder>() + .UseSqlite($"Data Source={groupingDbName}") + .Options; + var groupingContext = new CasbinDbContext(groupingOptions); + groupingContext.Database.EnsureCreated(); + + return new PolicyTypeContextProvider(policyContext, groupingContext); + } + + /// + /// Gets separate contexts for direct verification in tests. + /// Returns NEW context instances pointing to the same databases as the provider. + /// + public (CasbinDbContext policyContext, CasbinDbContext groupingContext) GetSeparateContexts(string testName) + { + // Use same database file names as GetMultiContextProvider + var policyDbName = $"MultiContext_{testName}_policy.db"; + var groupingDbName = $"MultiContext_{testName}_grouping.db"; + + // Create new context instances that point to the same database files + var policyOptions = new DbContextOptionsBuilder>() + .UseSqlite($"Data Source={policyDbName}") + .Options; + var policyContext = new CasbinDbContext(policyOptions); + policyContext.Database.EnsureCreated(); + + var groupingOptions = new DbContextOptionsBuilder>() + .UseSqlite($"Data Source={groupingDbName}") + .Options; + var groupingContext = new CasbinDbContext(groupingOptions); + groupingContext.Database.EnsureCreated(); + + return (policyContext, groupingContext); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed && disposing) + { + // Cleanup handled by test framework + _disposed = true; + } + } + } +} diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/PolicyTypeContextProvider.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/PolicyTypeContextProvider.cs new file mode 100644 index 0000000..8d1e1b4 --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/PolicyTypeContextProvider.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; + +namespace Casbin.Persist.Adapter.EFCore.UnitTest.Fixtures +{ + /// + /// Test context provider that routes policy types (p, p2, etc.) to one context + /// and grouping types (g, g2, etc.) to another context. + /// + public class PolicyTypeContextProvider : ICasbinDbContextProvider + { + private readonly CasbinDbContext _policyContext; + private readonly CasbinDbContext _groupingContext; + + public PolicyTypeContextProvider( + CasbinDbContext policyContext, + CasbinDbContext groupingContext) + { + _policyContext = policyContext ?? throw new ArgumentNullException(nameof(policyContext)); + _groupingContext = groupingContext ?? throw new ArgumentNullException(nameof(groupingContext)); + } + + public DbContext GetContextForPolicyType(string policyType) + { + if (string.IsNullOrEmpty(policyType)) + { + return _policyContext; + } + + // Route 'p' types (p, p2, p3, etc.) to policy context + // Route 'g' types (g, g2, g3, etc.) to grouping context + return policyType.StartsWith("p", StringComparison.OrdinalIgnoreCase) + ? _policyContext + : _groupingContext; + } + + public IEnumerable GetAllContexts() + { + return new DbContext[] { _policyContext, _groupingContext }; + } + + public System.Data.Common.DbConnection? GetSharedConnection() + { + // Return null since this provider uses separate SQLite database files + // (each context has its own connection) + return null; + } + } +} diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/AutoSaveTests.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/AutoSaveTests.cs new file mode 100644 index 0000000..4a0884f --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/AutoSaveTests.cs @@ -0,0 +1,982 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Casbin.Model; +using Casbin.Persist.Adapter.EFCore.Entities; +using Npgsql; +using Microsoft.EntityFrameworkCore; +using Xunit; +using Xunit.Abstractions; + +namespace Casbin.Persist.Adapter.EFCore.UnitTest.Integration +{ + /// + /// Custom context classes for multi-schema testing + /// + public class TestCasbinDbContext1 : CasbinDbContext + { + public TestCasbinDbContext1( + DbContextOptions> options, + string schemaName, + string tableName) + : base(options, schemaName, tableName) + { + } + } + + public class TestCasbinDbContext2 : CasbinDbContext + { + public TestCasbinDbContext2( + DbContextOptions> options, + string schemaName, + string tableName) + : base(options, schemaName, tableName) + { + } + } + + public class TestCasbinDbContext3 : CasbinDbContext + { + public TestCasbinDbContext3( + DbContextOptions> options, + string schemaName, + string tableName) + : base(options, schemaName, tableName) + { + } + } + + /// + /// Integration tests for AutoSave behavior using PostgreSQL. + /// These tests verify that the adapter correctly handles AutoSave ON and OFF modes + /// when working with both regular policies and grouping policies. + /// + /// Note: These are integration tests (not unit tests) because they: + /// - Use the full Casbin Enforcer (not just the adapter in isolation) + /// - Test the interaction between Enforcer and Adapter + /// - Use real PostgreSQL database (not SQLite in-memory) + /// + [Trait("Category", "Integration")] + [Collection("IntegrationTests")] + public class AutoSaveTests : TestUtil, IAsyncLifetime + { + private readonly TransactionIntegrityTestFixture _fixture; + private readonly ITestOutputHelper _output; + private const string ModelPath = "examples/multi_context_model.conf"; + + public AutoSaveTests(TransactionIntegrityTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + } + + public async Task InitializeAsync() + { + // Clear all policies before each test + await _fixture.ClearAllPoliciesAsync(); + } + + public async Task DisposeAsync() + { + // Restore any tables that may have been dropped during test execution + await _fixture.RunMigrationsAsync(); + } + + /// + /// Tests regular policies with AutoSave ON (default behavior). + /// Verifies that operations immediately persist to the database. + /// + [Fact] + public async Task TestPolicyAutoSaveOn() + { + await using var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder>() + .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.PoliciesSchema)) + .Options; + + await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.PoliciesSchema); + await InitPolicyAsync(context); + + var adapter = new EFCoreAdapter(context); + var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); + var enforcer = new Enforcer(model, adapter); + + #region Load policy test + TestGetPolicy(enforcer, AsList( + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write"), + AsList("data2_admin", "data2", "read"), + AsList("data2_admin", "data2", "write") + )); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5); + #endregion + + #region Add policy test + await enforcer.AddPolicyAsync("alice", "data1", "write"); + TestGetPolicy(enforcer, AsList( + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write"), + AsList("data2_admin", "data2", "read"), + AsList("data2_admin", "data2", "write"), + AsList("alice", "data1", "write") + )); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 6); + #endregion + + #region Remove policy test + await enforcer.RemovePolicyAsync("alice", "data1", "write"); + TestGetPolicy(enforcer, AsList( + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write"), + AsList("data2_admin", "data2", "read"), + AsList("data2_admin", "data2", "write") + )); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5); + + await enforcer.RemoveFilteredPolicyAsync(0, "data2_admin"); + TestGetPolicy(enforcer, AsList( + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write") + )); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 3); + #endregion + + #region Update policy test + await enforcer.UpdatePolicyAsync(AsList("alice", "data1", "read"), + AsList("alice", "data2", "write")); + TestGetPolicy(enforcer, AsList( + AsList("alice", "data2", "write"), + AsList("bob", "data2", "write") + )); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 3); + + await enforcer.UpdatePolicyAsync(AsList("alice", "data2", "write"), + AsList("alice", "data1", "read")); + TestGetPolicy(enforcer, AsList( + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write") + )); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 3); + #endregion + + #region Batch APIs test + await enforcer.AddPoliciesAsync(new [] + { + new System.Collections.Generic.List{"alice", "data2", "write"}, + new System.Collections.Generic.List{"bob", "data1", "read"} + }); + TestGetPolicy(enforcer, AsList( + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write"), + AsList("alice", "data2", "write"), + AsList("bob", "data1", "read") + )); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5); + + await enforcer.RemovePoliciesAsync(new [] + { + new System.Collections.Generic.List{"alice", "data1", "read"}, + new System.Collections.Generic.List{"bob", "data2", "write"} + }); + TestGetPolicy(enforcer, AsList( + AsList("alice", "data2", "write"), + AsList("bob", "data1", "read") + )); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 3); + #endregion + } + + /// + /// Tests async version of regular policies with AutoSave ON. + /// + [Fact] + public async Task TestPolicyAutoSaveOnAsync() + { + await using var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder>() + .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.PoliciesSchema)) + .Options; + + await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.PoliciesSchema); + await InitPolicyAsync(context); + + var adapter = new EFCoreAdapter(context); + var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); + var enforcer = new Enforcer(model, adapter); + + #region Load policy test + TestGetPolicy(enforcer, AsList( + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write"), + AsList("data2_admin", "data2", "read"), + AsList("data2_admin", "data2", "write") + )); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5); + #endregion + + #region Add policy test + await enforcer.AddPolicyAsync("alice", "data1", "write"); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 6); + #endregion + + #region Remove policy test + await enforcer.RemovePolicyAsync("alice", "data1", "write"); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5); + #endregion + } + + /// + /// Tests regular policies with AutoSave OFF. + /// Verifies that AddPolicy() correctly respects AutoSave OFF setting. + /// This documents the CORRECT behavior (contrast with grouping policy bug). + /// + [Fact] + public async Task TestPolicyAutoSaveOff() + { + await using var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder>() + .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.PoliciesSchema)) + .Options; + + await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.PoliciesSchema); + await InitPolicyAsync(context); + + var adapter = new EFCoreAdapter(context); + var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); + var enforcer = new Enforcer(model, adapter); + + // Disable AutoSave + enforcer.EnableAutoSave(false); + + // Verify initial state + Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); + + // Add policy - should NOT save to database with AutoSave OFF + enforcer.AddPolicy("charlie", "data3", "read"); + + // Verify policy was NOT saved yet (correct behavior for regular policies) + var countAfterAdd = await context.Policies.AsNoTracking().CountAsync(); + Assert.Equal(5, countAfterAdd); // Still 5 - CORRECT BEHAVIOR + + // Verify policy is NOT in database yet + var charlieBeforeSave = await context.Policies.AsNoTracking() + .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie"); + Assert.Null(charlieBeforeSave); // Not in database yet - CORRECT + + // When SavePolicy is called, it should save the policy + await enforcer.SavePolicyAsync(); + + // Now the policy should be in database + Assert.Equal(6, await context.Policies.AsNoTracking().CountAsync()); // 5 + 1 = 6 + + // Verify it's in database after SavePolicy + var charlieAfterSave = await context.Policies.AsNoTracking() + .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie"); + Assert.NotNull(charlieAfterSave); + } + + /// + /// Tests async version of regular policies with AutoSave OFF. + /// Verifies that AddPolicyAsync() correctly respects AutoSave OFF setting. + /// + [Fact] + public async Task TestPolicyAutoSaveOffAsync() + { + await using var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder>() + .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.PoliciesSchema)) + .Options; + + await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.PoliciesSchema); + await InitPolicyAsync(context); + + var adapter = new EFCoreAdapter(context); + var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); + var enforcer = new Enforcer(model, adapter); + + // Disable AutoSave + enforcer.EnableAutoSave(false); + + // Verify initial state + Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); + + // Add policy - should NOT save to database with AutoSave OFF + await enforcer.AddPolicyAsync("charlie", "data3", "read"); + + // Verify policy was NOT saved yet (correct behavior) + var countAfterAdd = await context.Policies.AsNoTracking().CountAsync(); + Assert.Equal(5, countAfterAdd); // Still 5 - CORRECT BEHAVIOR + + // When SavePolicy is called, it should save the policy + await enforcer.SavePolicyAsync(); + + // Now the policy should be in database + Assert.Equal(6, await context.Policies.AsNoTracking().CountAsync()); + + // Verify it's in database + var charlieAfterSave = await context.Policies.AsNoTracking() + .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie"); + Assert.NotNull(charlieAfterSave); + } + + /// + /// Tests grouping policies with AutoSave ON (default behavior). + /// This test verifies that AddGroupingPolicy() immediately saves to database. + /// + [Fact] + public async Task TestGroupingPolicyAutoSaveOn() + { + await using var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder>() + .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.GroupingsSchema)) + .Options; + + await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.GroupingsSchema); + await InitPolicyAsync(context); + + var adapter = new EFCoreAdapter(context); + var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); + var enforcer = new Enforcer(model, adapter); + + // Verify initial grouping policy + TestGetGroupingPolicy(enforcer, AsList( + AsList("alice", "data2_admin") + )); + Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); + + // Add grouping policy - should save immediately with AutoSave ON + await enforcer.AddGroupingPolicyAsync("bob", "data2_admin"); + + // Verify it's in Casbin's memory + TestGetGroupingPolicy(enforcer, AsList( + AsList("alice", "data2_admin"), + AsList("bob", "data2_admin") + )); + + // Verify it was saved to database immediately + Assert.Equal(6, await context.Policies.AsNoTracking().CountAsync()); + var bobGrouping = await context.Policies.AsNoTracking() + .FirstOrDefaultAsync(p => p.Type == "g" && p.Value1 == "bob" && p.Value2 == "data2_admin"); + Assert.NotNull(bobGrouping); + } + + /// + /// Tests grouping policies with AutoSave OFF. + /// + /// Verifies that AddGroupingPolicy() respects the EnableAutoSave(false) setting. + /// + /// Expected behavior (verified by this test): + /// - AddGroupingPolicy() should NOT save to database when AutoSave is OFF + /// - Only SavePolicy() should commit changes + /// + /// This test now passes with Casbin.NET 2.19.1+ which fixed the AutoSave bug. + /// + /// Related: Integration/README.md + /// + [Fact] + public async Task TestGroupingPolicyAutoSaveOff() + { + await using var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder>() + .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.GroupingsSchema)) + .Options; + + await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.GroupingsSchema); + await InitPolicyAsync(context); + + var adapter = new EFCoreAdapter(context); + var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); + var enforcer = new Enforcer(model, adapter); + + // Disable AutoSave + enforcer.EnableAutoSave(false); + + // Verify initial state + Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); + + // Add regular policy - should NOT save to database with AutoSave OFF + enforcer.AddPolicy("charlie", "data3", "read"); + + // Verify regular policy was NOT saved (correct behavior) + Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); + + // Add grouping policy - should NOT save to database with AutoSave OFF + await enforcer.AddGroupingPolicyAsync("bob", "data2_admin"); + + // TEST EXPECTATION: Grouping policy should NOT be saved yet (AutoSave is OFF) + // BUG: This will fail because Casbin.NET incorrectly saves it (Actual: 6, Expected: 5) + var savedCountAfterAdd = await context.Policies.AsNoTracking().CountAsync(); + Assert.Equal(5, savedCountAfterAdd); // FAILS due to bug: actual is 6 + + // Verify the grouping policy is NOT in database yet + var bobGroupingBeforeSave = await context.Policies.AsNoTracking() + .FirstOrDefaultAsync(p => p.Type == "g" && p.Value1 == "bob" && p.Value2 == "data2_admin"); + Assert.Null(bobGroupingBeforeSave); // FAILS due to bug: it exists + + // When SavePolicy is called, it should save BOTH policies + await enforcer.SavePolicyAsync(); + + // Now both policies should be in database + Assert.Equal(7, await context.Policies.AsNoTracking().CountAsync()); // 5 original + 1 charlie + 1 bob = 7 + + // Verify both are in database after SavePolicy + var charliePolicy = await context.Policies.AsNoTracking() + .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie"); + var bobGroupingAfterSave = await context.Policies.AsNoTracking() + .FirstOrDefaultAsync(p => p.Type == "g" && p.Value1 == "bob" && p.Value2 == "data2_admin"); + + Assert.NotNull(charliePolicy); + Assert.NotNull(bobGroupingAfterSave); + } + + /// + /// Tests async version of grouping policies with AutoSave OFF. + /// + /// Verifies that AddGroupingPolicyAsync() respects the EnableAutoSave(false) setting. + /// + /// Expected behavior (verified by this test): AddGroupingPolicyAsync() should NOT save when AutoSave is OFF. + /// + /// This test now passes with Casbin.NET 2.19.1+ which fixed the AutoSave bug. + /// + [Fact] + public async Task TestGroupingPolicyAutoSaveOffAsync() + { + await using var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder>() + .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.GroupingsSchema)) + .Options; + + await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.GroupingsSchema); + await InitPolicyAsync(context); + + var adapter = new EFCoreAdapter(context); + var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); + var enforcer = new Enforcer(model, adapter); + + // Disable AutoSave + enforcer.EnableAutoSave(false); + + // Verify initial state + Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); + + // Add regular policy - should NOT save to database with AutoSave OFF + await enforcer.AddPolicyAsync("charlie", "data3", "read"); + + // Verify regular policy was NOT saved (correct behavior) + Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); + + // Add grouping policy - should NOT save to database with AutoSave OFF + await enforcer.AddGroupingPolicyAsync("bob", "data2_admin"); + + // TEST EXPECTATION: Grouping policy should NOT be saved yet + // BUG: This will fail because Casbin.NET incorrectly saves it (Actual: 6, Expected: 5) + var savedCountAfterAdd = await context.Policies.AsNoTracking().CountAsync(); + Assert.Equal(5, savedCountAfterAdd); // FAILS due to bug: actual is 6 + + // When SavePolicy is called, it should save BOTH policies + await enforcer.SavePolicyAsync(); + + // Now both policies should be in database + Assert.Equal(7, await context.Policies.AsNoTracking().CountAsync()); // 5 original + 1 charlie + 1 bob = 7 + + // Verify both are in database + var charliePolicy = await context.Policies.AsNoTracking() + .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie"); + var bobGrouping = await context.Policies.AsNoTracking() + .FirstOrDefaultAsync(p => p.Type == "g" && p.Value1 == "bob" && p.Value2 == "data2_admin"); + + Assert.NotNull(charliePolicy); + Assert.NotNull(bobGrouping); + } + + #region Multi-Context AutoSave Tests + + /// + /// Provider that routes policy types to three separate contexts + /// + private class ThreeWayContextProvider : ICasbinDbContextProvider + { + private readonly CasbinDbContext _policyContext; + private readonly CasbinDbContext _groupingContext; + private readonly CasbinDbContext _roleContext; + private readonly System.Data.Common.DbConnection? _sharedConnection; + + public ThreeWayContextProvider( + CasbinDbContext policyContext, + CasbinDbContext groupingContext, + CasbinDbContext roleContext, + System.Data.Common.DbConnection? sharedConnection) + { + _policyContext = policyContext; + _groupingContext = groupingContext; + _roleContext = roleContext; + _sharedConnection = sharedConnection; + } + + public DbContext GetContextForPolicyType(string policyType) + { + return policyType switch + { + "p" => _policyContext, // p policies → casbin_policies schema + "g" => _groupingContext, // g groupings → casbin_groupings schema + "g2" => _roleContext, // g2 roles → casbin_roles schema + _ => _policyContext + }; + } + + public IEnumerable GetAllContexts() + { + return new[] { _policyContext, _groupingContext, _roleContext }; + } + + public System.Data.Common.DbConnection? GetSharedConnection() + { + return _sharedConnection; + } + } + + /// + /// Tests AutoSave OFF with multiple contexts and rollback on failure. + /// + /// Verifies that: + /// - With AutoSave OFF, policies batch in memory (not commit) + /// - SavePolicy() uses shared transaction and rolls back atomically on failure + /// + /// This test now passes with Casbin.NET 2.19.1+ which fixed the AutoSave bug. + /// + [Fact] + public async Task TestAutoSaveOff_MultiContext_RollbackOnFailure() + { + _output.WriteLine("=== AUTOSAVE OFF - MULTI-CONTEXT ATOMIC ROLLBACK TEST ==="); + _output.WriteLine("Goal: With AutoSave OFF, SavePolicy should use shared transaction and rollback atomically"); + _output.WriteLine(""); + + // Clear all data first + await _fixture.ClearAllPoliciesAsync(); + + // Create ONE shared connection + var sharedConnection = new NpgsqlConnection(_fixture.ConnectionString); + await sharedConnection.OpenAsync(); + + try + { + _output.WriteLine($"Shared connection: {sharedConnection.GetHashCode()}"); + _output.WriteLine(""); + + // Create three contexts using the SAME connection object + var options1 = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) + .Options; + var policyContext = new TestCasbinDbContext1(options1, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule"); + + var options2 = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) + .Options; + var groupingContext = new TestCasbinDbContext2(options2, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule"); + + var options3 = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) + .Options; + var roleContext = new TestCasbinDbContext3(options3, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule"); + + // Create provider and adapter + var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, sharedConnection); + var adapter = new EFCoreAdapter(provider); + + // Create enforcer and DISABLE AutoSave + var model = DefaultModel.CreateFromFile(ModelPath); + var enforcer = new Enforcer(model); + enforcer.SetAdapter(adapter); + enforcer.EnableAutoSave(false); // ← CRITICAL: Disable AutoSave + _output.WriteLine("AutoSave disabled"); + _output.WriteLine(""); + + // Add multiple policies to each type + _output.WriteLine("Adding policies with AutoSave OFF (should batch in memory):"); + enforcer.AddPolicy("alice", "data1", "read"); + enforcer.AddPolicy("bob", "data2", "write"); + _output.WriteLine(" Added 2 p policies"); + + enforcer.AddGroupingPolicy("alice", "admin"); + enforcer.AddGroupingPolicy("bob", "user"); + _output.WriteLine(" Added 2 g groupings"); + + enforcer.AddNamedGroupingPolicy("g2", "admin", "role-superuser"); + enforcer.AddNamedGroupingPolicy("g2", "user", "role-basic"); + _output.WriteLine(" Added 2 g2 roles"); + _output.WriteLine(""); + + // Check database state - should be EMPTY (policies batched, not committed) + var beforePoliciesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); + var beforeGroupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); + var beforeRolesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema); + _output.WriteLine($"STATE BEFORE DROP (should be 0,0,0): ({beforePoliciesCount}, {beforeGroupingsCount}, {beforeRolesCount})"); + + if (beforePoliciesCount == 0 && beforeGroupingsCount == 0 && beforeRolesCount == 0) + { + _output.WriteLine("✓ Confirmed: AutoSave OFF prevents immediate commits"); + } + else + { + _output.WriteLine("✗ WARNING: Policies were committed despite AutoSave OFF!"); + } + _output.WriteLine(""); + + // NOW: Drop the table for the third schema to force a failure + _output.WriteLine("FORCING FAILURE: Dropping casbin_roles.casbin_rule table..."); + await using (var cmd = sharedConnection.CreateCommand()) + { + cmd.CommandText = $"DROP TABLE {TransactionIntegrityTestFixture.RolesSchema}.casbin_rule"; + await cmd.ExecuteNonQueryAsync(); + } + _output.WriteLine("Table dropped!"); + _output.WriteLine(""); + + // Try to save - this SHOULD fail + _output.WriteLine("Calling SavePolicyAsync()... (expecting exception)"); + var exception = await Assert.ThrowsAnyAsync(async () => + { + await enforcer.SavePolicyAsync(); + }); + _output.WriteLine($"✓ Exception caught as expected: {exception.GetType().Name}"); + _output.WriteLine($" Message: {exception.Message}"); + _output.WriteLine(""); + + // Count policies in the first two schemas (third schema table is gone) + var policiesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); + + _output.WriteLine("RESULTS - Policies per schema after failure:"); + _output.WriteLine($" casbin_policies: {policiesCount}"); + _output.WriteLine($" casbin_groupings: {groupingsCount}"); + _output.WriteLine($" casbin_roles: N/A (table dropped)"); + _output.WriteLine(""); + + // ASSERT: With AutoSave OFF and shared connection, SavePolicy should roll back ALL changes + if (policiesCount == 0 && groupingsCount == 0) + { + _output.WriteLine("✓✓✓ AUTOSAVE OFF ATOMIC TRANSACTION TEST PASSED!"); + _output.WriteLine("SavePolicy used shared transaction and rolled back atomically"); + } + else + { + _output.WriteLine("✗✗✗ AUTOSAVE OFF ATOMIC TRANSACTION TEST FAILED!"); + _output.WriteLine($"Expected: (0, 0), Got: ({policiesCount}, {groupingsCount})"); + } + + Assert.Equal(0, policiesCount); + Assert.Equal(0, groupingsCount); + + await policyContext.DisposeAsync(); + await groupingContext.DisposeAsync(); + await roleContext.DisposeAsync(); + } + finally + { + await sharedConnection.DisposeAsync(); + } + } + + /// + /// Tests AutoSave ON with multiple contexts showing individual commits. + /// Verifies that each AddPolicy commits independently (no cross-context atomicity). + /// + [Fact] + public async Task TestAutoSaveOn_MultiContext_IndividualCommits() + { + _output.WriteLine("=== AUTOSAVE ON - INDIVIDUAL COMMITS TEST ==="); + _output.WriteLine("Goal: With AutoSave ON, each Add should commit independently (no atomicity)"); + _output.WriteLine(""); + + // Clear all data first + await _fixture.ClearAllPoliciesAsync(); + + // Create ONE shared connection + var sharedConnection = new NpgsqlConnection(_fixture.ConnectionString); + await sharedConnection.OpenAsync(); + + try + { + // Create three contexts using the SAME connection object + var options1 = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) + .Options; + var policyContext = new TestCasbinDbContext1(options1, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule"); + + var options2 = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) + .Options; + var groupingContext = new TestCasbinDbContext2(options2, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule"); + + var options3 = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) + .Options; + var roleContext = new TestCasbinDbContext3(options3, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule"); + + // Create provider and adapter + var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, sharedConnection); + var adapter = new EFCoreAdapter(provider); + + // Create enforcer with AutoSave ON (default) + var model = DefaultModel.CreateFromFile(ModelPath); + var enforcer = new Enforcer(model); + enforcer.SetAdapter(adapter); + // enforcer.EnableAutoSave(true); // Default is true, no need to set + _output.WriteLine("AutoSave enabled (default)"); + _output.WriteLine(""); + + // Add policies to context 1 and check DB immediately + _output.WriteLine("Step 1: Adding 2 p policies (should commit immediately):"); + enforcer.AddPolicy("alice", "data1", "read"); + enforcer.AddPolicy("bob", "data2", "write"); + var step1Count = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); + _output.WriteLine($" DB state after p policies: ({step1Count}, ?, ?)"); + Assert.Equal(2, step1Count); + _output.WriteLine(""); + + // Add policies to context 2 and check DB immediately + _output.WriteLine("Step 2: Adding 2 g policies (should commit immediately):"); + enforcer.AddGroupingPolicy("alice", "admin"); + enforcer.AddGroupingPolicy("bob", "user"); + var step2CountPolicy = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); + var step2CountGrouping = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); + _output.WriteLine($" DB state after g policies: ({step2CountPolicy}, {step2CountGrouping}, ?)"); + Assert.Equal(2, step2CountPolicy); + Assert.Equal(2, step2CountGrouping); + _output.WriteLine(""); + + // NOW: Drop the table for the third schema + _output.WriteLine("Step 3: Dropping casbin_roles.casbin_rule table..."); + await using (var cmd = sharedConnection.CreateCommand()) + { + cmd.CommandText = $"DROP TABLE {TransactionIntegrityTestFixture.RolesSchema}.casbin_rule"; + await cmd.ExecuteNonQueryAsync(); + } + _output.WriteLine("Table dropped!"); + _output.WriteLine(""); + + // Try to add policy to context 3 - should fail + _output.WriteLine("Step 4: Trying to add g2 policy (expecting exception):"); + var exception = await Assert.ThrowsAnyAsync(async () => + { + enforcer.AddNamedGroupingPolicy("g2", "admin", "role-superuser"); + }); + _output.WriteLine($"✓ Exception caught as expected: {exception.GetType().Name}"); + _output.WriteLine($" Message: {exception.Message}"); + _output.WriteLine(""); + + // Check final state - contexts 1 & 2 should still have their data + var finalPoliciesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); + var finalGroupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); + + _output.WriteLine("FINAL RESULTS:"); + _output.WriteLine($" casbin_policies: {finalPoliciesCount}"); + _output.WriteLine($" casbin_groupings: {finalGroupingsCount}"); + _output.WriteLine($" casbin_roles: N/A (table dropped)"); + _output.WriteLine(""); + + // ASSERT: With AutoSave ON, each context committed independently + if (finalPoliciesCount == 2 && finalGroupingsCount == 2) + { + _output.WriteLine("✓✓✓ AUTOSAVE ON INDIVIDUAL COMMITS TEST PASSED!"); + _output.WriteLine("Each AddPolicy committed independently, no cross-context atomicity"); + } + else + { + _output.WriteLine("✗✗✗ AUTOSAVE ON INDIVIDUAL COMMITS TEST FAILED!"); + _output.WriteLine($"Expected: (2, 2), Got: ({finalPoliciesCount}, {finalGroupingsCount})"); + } + + Assert.Equal(2, finalPoliciesCount); + Assert.Equal(2, finalGroupingsCount); + + await policyContext.DisposeAsync(); + await groupingContext.DisposeAsync(); + await roleContext.DisposeAsync(); + } + finally + { + await sharedConnection.DisposeAsync(); + } + } + + /// + /// Tests AutoSave OFF success path with batched commit across multiple contexts. + /// + /// Verifies that with AutoSave OFF, SavePolicy() batches all operations + /// in a shared transaction and commits atomically. + /// + /// This test now passes with Casbin.NET 2.19.1+ which fixed the AutoSave bug. + /// + [Fact] + public async Task TestAutoSaveOff_MultiContext_BatchedCommit() + { + _output.WriteLine("=== AUTOSAVE OFF - SUCCESS PATH TEST ==="); + _output.WriteLine("Goal: With AutoSave OFF, SavePolicy should batch all operations in shared transaction"); + _output.WriteLine(""); + + // Clear all data first + await _fixture.ClearAllPoliciesAsync(); + + // Create ONE shared connection + var sharedConnection = new NpgsqlConnection(_fixture.ConnectionString); + await sharedConnection.OpenAsync(); + + try + { + // Create three contexts + var options1 = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) + .Options; + var policyContext = new TestCasbinDbContext1(options1, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule"); + + var options2 = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) + .Options; + var groupingContext = new TestCasbinDbContext2(options2, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule"); + + var options3 = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) + .Options; + var roleContext = new TestCasbinDbContext3(options3, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule"); + + var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, sharedConnection); + var adapter = new EFCoreAdapter(provider); + + var model = DefaultModel.CreateFromFile(ModelPath); + var enforcer = new Enforcer(model); + enforcer.SetAdapter(adapter); + enforcer.EnableAutoSave(false); + _output.WriteLine("AutoSave disabled"); + _output.WriteLine(""); + + // Add policies + _output.WriteLine("Adding policies with AutoSave OFF:"); + enforcer.AddPolicy("alice", "data1", "read"); + enforcer.AddPolicy("bob", "data2", "write"); + enforcer.AddGroupingPolicy("alice", "admin"); + enforcer.AddGroupingPolicy("bob", "user"); + enforcer.AddNamedGroupingPolicy("g2", "admin", "role-superuser"); + enforcer.AddNamedGroupingPolicy("g2", "user", "role-basic"); + _output.WriteLine(" Added 6 policies total"); + _output.WriteLine(""); + + // Check DB before SavePolicy + var beforeCount1 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); + var beforeCount2 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); + var beforeCount3 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema); + _output.WriteLine($"DB state BEFORE SavePolicy: ({beforeCount1}, {beforeCount2}, {beforeCount3})"); + + if (beforeCount1 == 0 && beforeCount2 == 0 && beforeCount3 == 0) + { + _output.WriteLine("✓ Confirmed: Policies batched in memory, not committed yet"); + } + _output.WriteLine(""); + + // Call SavePolicy + _output.WriteLine("Calling SavePolicyAsync()..."); + await enforcer.SavePolicyAsync(); + _output.WriteLine("SavePolicyAsync() completed"); + _output.WriteLine(""); + + // Check final state + var finalCount1 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); + var finalCount2 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); + var finalCount3 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema); + + _output.WriteLine($"DB state AFTER SavePolicy: ({finalCount1}, {finalCount2}, {finalCount3})"); + _output.WriteLine(""); + + if (finalCount1 == 2 && finalCount2 == 2 && finalCount3 == 2) + { + _output.WriteLine("✓✓✓ AUTOSAVE OFF SUCCESS PATH TEST PASSED!"); + _output.WriteLine("SavePolicy committed all batched policies atomically"); + } + else + { + _output.WriteLine("✗✗✗ AUTOSAVE OFF SUCCESS PATH TEST FAILED!"); + _output.WriteLine($"Expected: (2, 2, 2), Got: ({finalCount1}, {finalCount2}, {finalCount3})"); + } + + Assert.Equal(2, finalCount1); + Assert.Equal(2, finalCount2); + Assert.Equal(2, finalCount3); + + await policyContext.DisposeAsync(); + await groupingContext.DisposeAsync(); + await roleContext.DisposeAsync(); + } + finally + { + await sharedConnection.DisposeAsync(); + } + } + + #endregion + + private static async Task InitPolicyAsync(CasbinDbContext context) + { + // Clear existing policies - use AsNoTracking to avoid concurrency exceptions + // when policies may have been deleted by fixture cleanup + var existing = await context.Policies.AsNoTracking().ToListAsync(); + if (existing.Any()) + { + context.Policies.AttachRange(existing); + context.Policies.RemoveRange(existing); + await context.SaveChangesAsync(); + } + + // Add test data + context.Policies.Add(new EFCorePersistPolicy() + { + Type = "p", + Value1 = "alice", + Value2 = "data1", + Value3 = "read", + }); + context.Policies.Add(new EFCorePersistPolicy() + { + Type = "p", + Value1 = "bob", + Value2 = "data2", + Value3 = "write", + }); + context.Policies.Add(new EFCorePersistPolicy() + { + Type = "p", + Value1 = "data2_admin", + Value2 = "data2", + Value3 = "read", + }); + context.Policies.Add(new EFCorePersistPolicy() + { + Type = "p", + Value1 = "data2_admin", + Value2 = "data2", + Value3 = "write", + }); + context.Policies.Add(new EFCorePersistPolicy() + { + Type = "g", + Value1 = "alice", + Value2 = "data2_admin", + }); + await context.SaveChangesAsync(); + } + } +} diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/IntegrationTestCollection.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/IntegrationTestCollection.cs new file mode 100644 index 0000000..cbc4ced --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/IntegrationTestCollection.cs @@ -0,0 +1,20 @@ +using Xunit; + +namespace Casbin.Persist.Adapter.EFCore.UnitTest.Integration +{ + /// + /// Collection definition for integration tests. + /// This ensures all test classes marked with [Collection("IntegrationTests")] + /// share a single TransactionIntegrityTestFixture instance. + /// + /// DisableParallelization = true ensures tests run sequentially to prevent + /// race conditions and schema conflicts. + /// + [CollectionDefinition("IntegrationTests", DisableParallelization = true)] + public class IntegrationTestCollection : ICollectionFixture + { + // This class has no code, and is never instantiated. + // Its purpose is simply to be the place to apply [CollectionDefinition] + // and all the ICollectionFixture<> interfaces. + } +} diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/README.md b/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/README.md new file mode 100644 index 0000000..53111a5 --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/README.md @@ -0,0 +1,307 @@ +# Integration Tests for Multi-Context Transaction Integrity + +This directory contains integration tests that verify the transaction integrity guarantees of the multi-context EFCore adapter feature. + +## ✅ Casbin.NET AutoSave Bug - FIXED in v2.19.1 + +### Test Status Summary + +| Test | Status | Description | +|------|--------|-------------| +| **AutoSaveTests.TestAutoSaveOn_MultiContext_IndividualCommits** | ✅ PASSING | Documents non-atomic behavior with AutoSave ON (expected) | +| **AutoSaveTests.TestAutoSaveOff_MultiContext_RollbackOnFailure** | ✅ PASSING | Verifies atomic rollback with AutoSave OFF | +| **AutoSaveTests.TestAutoSaveOff_MultiContext_BatchedCommit** | ✅ PASSING | Verifies batched commit with AutoSave OFF | +| **AutoSaveTests.TestGroupingPolicyAutoSaveOff** | ✅ PASSING | Single-context sync version | +| **AutoSaveTests.TestGroupingPolicyAutoSaveOffAsync** | ✅ PASSING | Single-context async version | + +### Bug History (RESOLVED) + +Casbin.NET had a bug where `AddGroupingPolicy()` and `AddNamedGroupingPolicy()` ignored the `EnableAutoSave(false)` setting. This was fixed in **Casbin.NET v2.19.1**. + +**Previous behavior (bug):** +- ✅ `AddPolicy()` respected AutoSave OFF (didn't call adapter) +- ❌ `AddGroupingPolicy()` ignored AutoSave OFF (called adapter immediately) +- ❌ `AddNamedGroupingPolicy()` ignored AutoSave OFF (called adapter immediately) + +**Current behavior (fixed in v2.19.1):** +- ✅ All policy methods now respect the `EnableAutoSave(false)` setting +- ✅ Policies stay in memory until `SavePolicy()` is called +- ✅ Atomic transactions work correctly with AutoSave OFF + +### Diagnostic Logging (Can Be Removed) + +The adapter code may still include diagnostic Console.WriteLine statements that were used to debug the bug. These can now be removed as the issue is resolved: +- Shows when adapter methods are called +- Shows call stacks proving Casbin.NET is calling the adapter +- Shows SaveChanges() being invoked despite AutoSave OFF + +--- + +## Purpose + +These tests prove that when multiple `DbContext` instances share the same `DbConnection` object, operations across contexts are **atomic** - they either all succeed or all fail together. + +## Prerequisites + +### 1. PostgreSQL Installation + +You need PostgreSQL running locally on your development machine. + +**Install PostgreSQL:** +- **Windows**: Download from [postgresql.org](https://www.postgresql.org/download/windows/) +- **macOS**: `brew install postgresql@17` (or use [Postgres.app](https://postgresapp.com/)) +- **Linux**: `sudo apt-get install postgresql` (Debian/Ubuntu) or equivalent + +### 2. Database Setup + +Create the test database: + +```bash +# Connect to PostgreSQL (default superuser is 'postgres') +psql -U postgres + +# Create the test database +CREATE DATABASE casbin_integration_test; + +# Exit psql +\q +``` + +Alternatively, use a one-liner: + +```bash +psql -U postgres -c "CREATE DATABASE casbin_integration_test;" +``` + +### 3. Connection Credentials + +The tests use these default credentials: +- **Host**: `localhost:5432` +- **Database**: `casbin_integration_test` +- **Username**: `postgres` +- **Password**: `postgres` + +**If your PostgreSQL uses different credentials**, update the connection string in [TransactionIntegrityTestFixture.cs](TransactionIntegrityTestFixture.cs): + +```csharp +ConnectionString = "Host=localhost;Database=casbin_integration_test;Username=YOUR_USER;Password=YOUR_PASSWORD"; +``` + +## Running the Tests + +### Run All Integration Tests + +```bash +dotnet test --filter "Category=Integration" +``` + +### Run a Specific Test + +```bash +dotnet test --filter "FullyQualifiedName~SavePolicy_WithSharedConnection_ShouldWriteToAllContextsAtomically" +``` + +### Run with Specific Framework + +```bash +dotnet test --filter "Category=Integration" -f net9.0 +``` + +## Test Architecture + +### Test Fixture + +The [TransactionIntegrityTestFixture](TransactionIntegrityTestFixture.cs) automatically: +1. Creates 3 PostgreSQL schemas: `casbin_policies`, `casbin_groupings`, `casbin_roles` +2. Creates tables in each schema +3. Clears all data before each test +4. Cleans up schemas after all tests complete + +### Test Organization + +The integration tests are organized into 3 test classes: + +| Test Class | Tests | Purpose | +|------------|-------|---------| +| `TransactionIntegrityTests` | 7 | Multi-context transaction atomicity and rollback | +| `AutoSaveTests` | 11 | Casbin.NET AutoSave behavior verification | +| `SchemaDistributionTests` | 2 | Schema routing with shared connections | + +**Total:** 20 integration tests + +The tests use a three-way context provider that routes: +- **p policies** → `casbin_policies` schema +- **g groupings** → `casbin_groupings` schema +- **g2 roles** → `casbin_roles` schema + +This simulates real-world multi-context scenarios where different policy types are stored separately for compliance, multi-tenancy, or organizational requirements. + +## Test Coverage + +| Test | What It Proves | +|------|----------------| +| `SavePolicy_WithSharedConnection_ShouldWriteToAllContextsAtomically` | Policies written to 3 schemas in a single atomic transaction | +| `MultiContextSetup_WithSharedConnection_ShouldShareSamePhysicalConnection` | Reference equality confirms DbConnection object sharing | +| `SavePolicy_WhenDuplicateKeyViolationInOneContext_ShouldRollbackAllContexts` | **CRITICAL**: Failure in one context rolls back ALL contexts | +| `SavePolicy_WhenTableMissingInOneContext_ShouldRollbackAllContexts` | Severe failures cause complete rollback | +| `MultipleSaveOperations_WithSharedConnection_ShouldMaintainDataConsistency` | Multiple operations maintain consistency over time | +| `SavePolicy_WithSeparateConnections_ShouldNotBeAtomic` | **Negative test**: Proves separate connections are NOT atomic | +| `SavePolicy_ShouldReflectDatabaseStateNotCasbinMemory` | Tests verify actual database state, not just Casbin memory | + +### SchemaDistributionTests + +**File:** [SchemaDistributionTests.cs](SchemaDistributionTests.cs) +**Test Count:** 2 +**Status:** ✅ All Passing + +**Purpose:** + +These tests verify that `CasbinDbContext.HasDefaultSchema()` correctly routes policies to their designated schemas when using shared connections, ensuring schema isolation is maintained. + +**Test Coverage:** + +| Test | Purpose | Status | +|------|---------|--------| +| `BaselineSchemaDistributionTest` | Baseline behavior with separate connections | ✅ Passing | +| `SchemaDistributionTest_WithSharedConnection` | Schema routing with shared connection | ✅ Passing | + +**What They Test:** + +1. **Schema Routing:** + - `p` policies → `casbin_policies` schema + - `g` policies → `casbin_groupings` schema + - `g2` policies → `casbin_roles` schema + +2. **Shared Connection Impact:** + - Verifies `HasDefaultSchema()` returns correct schema name per context + - Confirms shared connection doesn't break schema isolation + - Validates multi-context routing works correctly + +3. **Database Verification:** + - Direct SQL queries to each schema + - Counts policies by type in each schema + - Asserts correct distribution (e.g., only `p` types in `casbin_policies` schema) + +**Why This Matters:** + +When using a shared connection for atomic transactions, each context must still route to its correct schema. These tests prove that sharing a connection object doesn't accidentally merge contexts or route to wrong schemas. + +**Running the Tests:** + +```bash +# Run both SchemaDistributionTests +dotnet test -f net6.0 --filter "FullyQualifiedName~SchemaDistributionTests" --verbosity normal + +# Run specific test +dotnet test -f net6.0 --filter "FullyQualifiedName~SchemaDistributionTest_WithSharedConnection" --verbosity normal +``` + +### AutoSaveTests + +**File:** [AutoSaveTests.cs](AutoSaveTests.cs) +**Test Count:** 11 +**Status:** ✅ All Passing + +**Purpose:** + +These tests verify the Casbin Enforcer's `EnableAutoSave` behavior in multi-context scenarios and prove that `EnableAutoSave(false)` is required for atomic rollback testing. + +**Key Tests:** + +| Test | What It Proves | Status | +|------|----------------|--------| +| `TestPolicyAutoSaveOn` / `TestPolicyAutoSaveOnAsync` | AutoSave ON commits immediately | ✅ Passing | +| `TestPolicyAutoSaveOff` | AutoSave OFF defers until SavePolicy | ✅ Passing | +| `TestGroupingPolicyAutoSaveOn` | Grouping policies also commit immediately | ✅ Passing | +| `TestGroupingPolicyAutoSaveOff` | Grouping policies defer with AutoSave OFF | ✅ Passing | +| `TestAutoSaveOn_MultiContext_IndividualCommits` | Multi-context: operations commit independently | ✅ Passing | +| `TestAutoSaveOff_MultiContext_RollbackOnFailure` | Multi-context: atomic rollback with AutoSave OFF | ✅ Passing | +| `TestRemovePolicyAutoSaveOn` / `Off` | Remove operations respect AutoSave | ✅ Passing | +| `TestUpdatePolicyAutoSaveOn` | Update operations respect AutoSave | ✅ Passing | + +**Why AutoSave Testing Matters:** + +The rollback tests in `TransactionIntegrityTests` require `enforcer.EnableAutoSave(false)` (lines 302, 370) because: +- With AutoSave ON: Policies commit immediately when `AddPolicyAsync()` is called +- With AutoSave OFF: Policies stay in memory until `SavePolicyAsync()` is called +- Atomic rollback testing requires all policies to be part of the same transaction + +**See:** [MULTI_CONTEXT_USAGE_GUIDE.md - EnableAutoSave and Transaction Atomicity](../../MULTI_CONTEXT_USAGE_GUIDE.md#enableautosave-and-transaction-atomicity) for detailed explanation. + +## Why These Tests Are Excluded from CI/CD + +These tests are marked with `[Trait("Category", "Integration")]` and **excluded from CI/CD** because: + +1. **Pipeline Ownership**: The CI/CD pipeline is not owned by this project's maintainers +2. **External Dependency**: Requires a PostgreSQL instance with specific configuration +3. **Local Verification**: These tests are for **local verification only** - they prove the documented transaction guarantees work correctly + +## Troubleshooting + +### Error: "could not connect to server" + +PostgreSQL is not running. Start it: +- **Windows**: Open Services → Start "postgresql-x64-XX" +- **macOS (Homebrew)**: `brew services start postgresql@17` +- **Linux**: `sudo systemctl start postgresql` + +### Error: "database 'casbin_integration_test' does not exist" + +Create the database: +```bash +psql -U postgres -c "CREATE DATABASE casbin_integration_test;" +``` + +### Error: "password authentication failed for user 'postgres'" + +Either: +1. Update your PostgreSQL password: `ALTER USER postgres PASSWORD 'postgres';` +2. Or update the connection string in [TransactionIntegrityTestFixture.cs](TransactionIntegrityTestFixture.cs) to match your credentials + +### Error: "relation 'casbin_rule' does not exist" + +The test fixture should create tables automatically. If this fails: +1. Ensure the database exists +2. Ensure the user has CREATE privileges: `GRANT ALL PRIVILEGES ON DATABASE casbin_integration_test TO postgres;` +3. Try manually creating schemas: `CREATE SCHEMA casbin_policies;` etc. + +## Verification of Transaction Guarantees + +### Critical Rollback Tests + +The **most critical tests** are the rollback verification tests: +- `SavePolicy_WhenTableDroppedInOneContext_ShouldRollbackAllContexts` +- `SavePolicy_WhenTableMissingInOneContext_ShouldRollbackAllContexts` + +**Key Implementation Detail:** + +These tests call `enforcer.EnableAutoSave(false)` immediately after creating the enforcer (lines 302, 370 in `TransactionIntegrityTests.cs`). This is **critical** because: + +- **With AutoSave ON (default):** `AddPolicyAsync()` commits immediately to database. When `SavePolicyAsync()` is called later and fails, it only rolls back DELETE operations, not the earlier INSERT operations that already committed. + +- **With AutoSave OFF:** Policies stay in-memory until `SavePolicyAsync()` is called. When the transaction fails, ALL operations (INSERT and DELETE) roll back atomically. + +**Test Evolution:** + +These tests originally failed with "Expected: 0, Actual: 1-2" because they used AutoSave ON. Adding `EnableAutoSave(false)` fixed them by ensuring proper rollback verification. + +**Code Reference:** See lines 302, 370 in [TransactionIntegrityTests.cs](TransactionIntegrityTests.cs) + +### Duplicate Key Violation Test + +The test `SavePolicy_WhenDuplicateKeyViolationInOneContext_ShouldRollbackAllContexts` verifies rollback on constraint violations. + +This test: +1. Inserts a policy directly into the `casbin_roles` schema +2. Adds NEW policies to `casbin_policies` and `casbin_groupings` schemas +3. Manipulates Casbin's in-memory model to create a duplicate in the `casbin_roles` schema +4. Calls `SavePolicyAsync()` which should fail due to the duplicate +5. **Verifies that NO policies were written** to ANY schema (complete rollback) + +This proves that the shared transaction mechanism works correctly - when one context fails, all contexts roll back atomically. + +## See Also + +- [MULTI_CONTEXT_DESIGN.md](../../MULTI_CONTEXT_DESIGN.md) - Technical design and architecture +- [MULTI_CONTEXT_USAGE_GUIDE.md](../../MULTI_CONTEXT_USAGE_GUIDE.md) - User-facing usage guide +- [Main README](../../README.md) - Project overview diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/SchemaDistributionTests.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/SchemaDistributionTests.cs new file mode 100644 index 0000000..29cacf2 --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/SchemaDistributionTests.cs @@ -0,0 +1,340 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Casbin; +using Casbin.Model; +using Npgsql; +using Microsoft.EntityFrameworkCore; +using Xunit; +using Xunit.Abstractions; + +namespace Casbin.Persist.Adapter.EFCore.UnitTest.Integration +{ + /// + /// Tests to verify whether HasDefaultSchema() properly distributes policies across PostgreSQL schemas, + /// both with separate connections and with shared connections. + /// + /// Purpose: Determine if explicit SET search_path is necessary or if EF Core's HasDefaultSchema() + /// generates schema-qualified SQL that works correctly with shared connections. + /// + [Trait("Category", "Integration")] + [Collection("IntegrationTests")] + public class SchemaDistributionTests : IClassFixture, IAsyncLifetime + { + private readonly TransactionIntegrityTestFixture _fixture; + private readonly ITestOutputHelper _output; + private const string ModelPath = "examples/multi_context_model.conf"; + + public SchemaDistributionTests(TransactionIntegrityTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + } + + public Task InitializeAsync() => _fixture.ClearAllPoliciesAsync(); + public Task DisposeAsync() => _fixture.RunMigrationsAsync(); + + #region Helper: Derived Context Classes + + /// + /// Derived context for policies schema + /// + public class TestCasbinDbContext1 : CasbinDbContext + { + public TestCasbinDbContext1( + DbContextOptions> options, + string schemaName, + string tableName) + : base(options, schemaName, tableName) + { + } + } + + /// + /// Derived context for groupings schema + /// + public class TestCasbinDbContext2 : CasbinDbContext + { + public TestCasbinDbContext2( + DbContextOptions> options, + string schemaName, + string tableName) + : base(options, schemaName, tableName) + { + } + } + + /// + /// Derived context for roles schema + /// + public class TestCasbinDbContext3 : CasbinDbContext + { + public TestCasbinDbContext3( + DbContextOptions> options, + string schemaName, + string tableName) + : base(options, schemaName, tableName) + { + } + } + + #endregion + + #region Helper: Three-Context Provider + + /// + /// Provider that routes policy types to three separate contexts + /// + private class ThreeWayContextProvider : ICasbinDbContextProvider + { + private readonly CasbinDbContext _policyContext; + private readonly CasbinDbContext _groupingContext; + private readonly CasbinDbContext _roleContext; + private readonly System.Data.Common.DbConnection? _sharedConnection; + + public ThreeWayContextProvider( + CasbinDbContext policyContext, + CasbinDbContext groupingContext, + CasbinDbContext roleContext, + System.Data.Common.DbConnection? sharedConnection) + { + _policyContext = policyContext; + _groupingContext = groupingContext; + _roleContext = roleContext; + _sharedConnection = sharedConnection; + } + + public DbContext GetContextForPolicyType(string policyType) + { + return policyType switch + { + "p" => _policyContext, // p policies → casbin_policies schema + "g" => _groupingContext, // g groupings → casbin_groupings schema + "g2" => _roleContext, // g2 roles → casbin_roles schema + _ => _policyContext + }; + } + + public IEnumerable GetAllContexts() + { + return new[] { _policyContext, _groupingContext, _roleContext }; + } + + public System.Data.Common.DbConnection? GetSharedConnection() + { + return _sharedConnection; + } + } + + #endregion + + #region Test 1: Separate Connections (Control/Baseline) + + /// + /// BASELINE TEST: Proves that HasDefaultSchema() correctly distributes policies across schemas + /// when contexts use SEPARATE connections (no shared connection). + /// + /// This is the baseline that should work regardless of any SET search_path logic. + /// + [Fact] + public async Task SavePolicy_SeparateConnections_ShouldDistributeAcrossSchemas() + { + _output.WriteLine("=== TEST: Separate Connections - Schema Distribution ==="); + + // Create three contexts with SEPARATE connection strings (no shared connection) + var policyOptions = new DbContextOptionsBuilder>() + .UseNpgsql(_fixture.ConnectionString) // Connection #1 + .Options; + var policyContext = new TestCasbinDbContext1(policyOptions, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule"); + + var groupingOptions = new DbContextOptionsBuilder>() + .UseNpgsql(_fixture.ConnectionString) // Connection #2 + .Options; + var groupingContext = new TestCasbinDbContext2(groupingOptions, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule"); + + var roleOptions = new DbContextOptionsBuilder>() + .UseNpgsql(_fixture.ConnectionString) // Connection #3 + .Options; + var roleContext = new TestCasbinDbContext3(roleOptions, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule"); + + _output.WriteLine("Created three contexts with SEPARATE connections"); + + // Verify they are different connection objects + var conn1 = policyContext.Database.GetDbConnection(); + var conn2 = groupingContext.Database.GetDbConnection(); + var conn3 = roleContext.Database.GetDbConnection(); + + Assert.False(ReferenceEquals(conn1, conn2), "Connections 1 and 2 should be different objects"); + Assert.False(ReferenceEquals(conn2, conn3), "Connections 2 and 3 should be different objects"); + _output.WriteLine("Verified: Contexts use DIFFERENT DbConnection objects"); + + try + { + // Create provider and adapter + // Pass null since these contexts use separate connections + var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, null); + var adapter = new EFCoreAdapter(provider); + + // Create enforcer without loading policy (tables might be empty) + var model = DefaultModel.CreateFromFile(ModelPath); + var enforcer = new Enforcer(model); + enforcer.SetAdapter(adapter); + + // Add policies to in-memory model (not persisted yet) + enforcer.AddPolicy("alice", "data1", "read"); // → casbin_policies + enforcer.AddGroupingPolicy("alice", "admin"); // → casbin_groupings + enforcer.AddNamedGroupingPolicy("g2", "admin", "role-superuser"); // → casbin_roles + + _output.WriteLine("Added policies to in-memory model:"); + _output.WriteLine(" p policy → casbin_policies"); + _output.WriteLine(" g policy → casbin_groupings"); + _output.WriteLine(" g2 policy → casbin_roles"); + + // Save to database + await enforcer.SavePolicyAsync(); + _output.WriteLine("Called SavePolicyAsync()"); + + // Verify distribution across schemas + var policiesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); + var rolesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema); + + _output.WriteLine($"Schema distribution:"); + _output.WriteLine($" casbin_policies: {policiesCount} policy"); + _output.WriteLine($" casbin_groupings: {groupingsCount} policy"); + _output.WriteLine($" casbin_roles: {rolesCount} policy"); + + // CRITICAL ASSERTION: Policies should be distributed across all three schemas + Assert.Equal(1, policiesCount); + Assert.Equal(1, groupingsCount); + Assert.Equal(1, rolesCount); + + _output.WriteLine("✓ BASELINE TEST PASSED: HasDefaultSchema() distributes policies correctly with separate connections"); + } + finally + { + await policyContext.DisposeAsync(); + await groupingContext.DisposeAsync(); + await roleContext.DisposeAsync(); + } + } + + #endregion + + #region Test 2: Shared Connection (Critical Test) + + /// + /// CRITICAL TEST: Determines if HasDefaultSchema() correctly distributes policies across schemas + /// when contexts share a SINGLE DbConnection object. + /// + /// If this test FAILS: SET search_path approach is necessary + /// If this test PASSES: SET search_path approach is NOT necessary + /// + [Fact] + public async Task SavePolicy_SharedConnection_ShouldDistributeAcrossSchemas() + { + _output.WriteLine("=== TEST: Shared Connection - Schema Distribution ==="); + + // Create ONE shared connection (CRITICAL for this test) + var sharedConnection = new NpgsqlConnection(_fixture.ConnectionString); + await sharedConnection.OpenAsync(); + _output.WriteLine("Opened shared connection for all three contexts"); + + try + { + // Create three contexts using SAME connection object + var policyOptions = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) // ✅ Shared connection + .Options; + var policyContext = new TestCasbinDbContext1(policyOptions, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule"); + + var groupingOptions = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) // ✅ Same connection + .Options; + var groupingContext = new TestCasbinDbContext2(groupingOptions, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule"); + + var roleOptions = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) // ✅ Same connection + .Options; + var roleContext = new TestCasbinDbContext3(roleOptions, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule"); + + _output.WriteLine("Created three contexts sharing the same connection"); + + // Verify reference equality + var conn1 = policyContext.Database.GetDbConnection(); + var conn2 = groupingContext.Database.GetDbConnection(); + var conn3 = roleContext.Database.GetDbConnection(); + + Assert.True(ReferenceEquals(conn1, conn2), "Connections 1 and 2 should be the SAME object"); + Assert.True(ReferenceEquals(conn2, conn3), "Connections 2 and 3 should be the SAME object"); + _output.WriteLine("Verified: All contexts share the SAME DbConnection object (reference equality)"); + + // Create provider and adapter + // Pass sharedConnection since all contexts share it + var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, sharedConnection); + var adapter = new EFCoreAdapter(provider); + + // Create enforcer without loading policy (tables might be empty) + var model = DefaultModel.CreateFromFile(ModelPath); + var enforcer = new Enforcer(model); + enforcer.SetAdapter(adapter); + + // Add policies to in-memory model (not persisted yet) + enforcer.AddPolicy("bob", "data2", "write"); // → casbin_policies + enforcer.AddGroupingPolicy("bob", "developer"); // → casbin_groupings + enforcer.AddNamedGroupingPolicy("g2", "developer", "role-contributor"); // → casbin_roles + + _output.WriteLine("Added policies to in-memory model:"); + _output.WriteLine(" p policy → should go to casbin_policies"); + _output.WriteLine(" g policy → should go to casbin_groupings"); + _output.WriteLine(" g2 policy → should go to casbin_roles"); + + // Save to database + await enforcer.SavePolicyAsync(); + _output.WriteLine("Called SavePolicyAsync()"); + + // Verify distribution across schemas + var policiesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); + var rolesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema); + + _output.WriteLine($"Schema distribution:"); + _output.WriteLine($" casbin_policies: {policiesCount} policy"); + _output.WriteLine($" casbin_groupings: {groupingsCount} policy"); + _output.WriteLine($" casbin_roles: {rolesCount} policy"); + + // CRITICAL ASSERTION: Policies should be distributed across all three schemas + // If all policies end up in ONE schema, HasDefaultSchema() does NOT work with shared connections + // and we NEED the SET search_path approach + + if (policiesCount == 1 && groupingsCount == 1 && rolesCount == 1) + { + _output.WriteLine("✓✓✓ SHARED CONNECTION TEST PASSED!"); + _output.WriteLine("HasDefaultSchema() correctly distributes policies even with shared connection"); + _output.WriteLine("CONCLUSION: SET search_path approach is NOT necessary"); + } + else + { + _output.WriteLine("✗✗✗ SHARED CONNECTION TEST FAILED!"); + _output.WriteLine($"Expected distribution: (1, 1, 1), Got: ({policiesCount}, {groupingsCount}, {rolesCount})"); + _output.WriteLine("CONCLUSION: SET search_path approach IS necessary for shared connections"); + } + + Assert.Equal(1, policiesCount); + Assert.Equal(1, groupingsCount); + Assert.Equal(1, rolesCount); + + await policyContext.DisposeAsync(); + await groupingContext.DisposeAsync(); + await roleContext.DisposeAsync(); + } + finally + { + await sharedConnection.DisposeAsync(); + } + } + + #endregion + } +} diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/TransactionIntegrityTestFixture.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/TransactionIntegrityTestFixture.cs new file mode 100644 index 0000000..142c51f --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/TransactionIntegrityTestFixture.cs @@ -0,0 +1,236 @@ +using System; +using System.Threading.Tasks; +using Npgsql; +using Xunit; + +namespace Casbin.Persist.Adapter.EFCore.UnitTest.Integration +{ + /// + /// Test fixture for transaction integrity tests using PostgreSQL. + /// Creates three separate schemas to simulate multi-context scenarios. + /// + /// Prerequisites: + /// - PostgreSQL running on localhost:5432 + /// - Database "casbin_integration_test" must exist + /// - Default credentials: postgres/postgres (or update ConnectionString) + /// + public class TransactionIntegrityTestFixture : IAsyncLifetime + { + // Schema names for three-way context split + public const string PoliciesSchema = "casbin_policies"; + public const string GroupingsSchema = "casbin_groupings"; + public const string RolesSchema = "casbin_roles"; + + // Connection string to local PostgreSQL + public string ConnectionString { get; private set; } + + public TransactionIntegrityTestFixture() + { + // Use local PostgreSQL for integration tests + // Database must exist before running tests + ConnectionString = "Host=localhost;Database=casbin_integration_test;Username=synapse;Password=synapsepwd"; + Console.WriteLine("[FIXTURE] Constructor called"); + } + + public async Task InitializeAsync() + { + Console.WriteLine("[FIXTURE] InitializeAsync START"); + try + { + // Create schemas + Console.WriteLine("[FIXTURE] Creating schemas..."); + await CreateSchemasAsync(); + Console.WriteLine("[FIXTURE] Schemas created"); + + // Run migrations for all three schemas + Console.WriteLine("[FIXTURE] Running migrations..."); + await RunMigrationsAsync(); + Console.WriteLine("[FIXTURE] Migrations complete - tables should now exist with v0-v13"); + } + catch (Exception ex) + { + Console.WriteLine($"[FIXTURE INITIALIZATION FAILED]"); + Console.WriteLine($"Error: {ex.Message}"); + Console.WriteLine($"Stack trace: {ex.StackTrace}"); + throw new InvalidOperationException( + $"Failed to initialize TransactionIntegrityTestFixture. " + + $"Ensure PostgreSQL is running and database 'casbin_integration_test' exists. " + + $"Connection string: {ConnectionString}", ex); + } + } + + public async Task DisposeAsync() + { + // Clean up test schemas + // TEMPORARILY DISABLED: Comment out to leave tables for inspection + // await DropSchemasAsync(); + await Task.CompletedTask; + } + + private async Task CreateSchemasAsync() + { + await using var connection = new NpgsqlConnection(ConnectionString); + await connection.OpenAsync(); + + await using var cmd = connection.CreateCommand(); + + // Create policies schema + cmd.CommandText = $"CREATE SCHEMA IF NOT EXISTS {PoliciesSchema}"; + await cmd.ExecuteNonQueryAsync(); + + // Create groupings schema + cmd.CommandText = $"CREATE SCHEMA IF NOT EXISTS {GroupingsSchema}"; + await cmd.ExecuteNonQueryAsync(); + + // Create roles schema + cmd.CommandText = $"CREATE SCHEMA IF NOT EXISTS {RolesSchema}"; + await cmd.ExecuteNonQueryAsync(); + } + + /// + /// Runs migrations for all schemas. Public so tests can restore tables after dropping them. + /// + public async Task RunMigrationsAsync() + { + await using var connection = new NpgsqlConnection(ConnectionString); + await connection.OpenAsync(); + + foreach (var schemaName in new[] { PoliciesSchema, GroupingsSchema, RolesSchema }) + { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = $@" + CREATE TABLE IF NOT EXISTS {schemaName}.casbin_rule ( + id SERIAL PRIMARY KEY, + ptype VARCHAR(254) NOT NULL, + v0 VARCHAR(254), + v1 VARCHAR(254), + v2 VARCHAR(254), + v3 VARCHAR(254), + v4 VARCHAR(254), + v5 VARCHAR(254), + v6 VARCHAR(254), + v7 VARCHAR(254), + v8 VARCHAR(254), + v9 VARCHAR(254), + v10 VARCHAR(254), + v11 VARCHAR(254), + v12 VARCHAR(254), + v13 VARCHAR(254) + ); + CREATE INDEX IF NOT EXISTS ix_casbin_rule_ptype ON {schemaName}.casbin_rule (ptype); + "; + await cmd.ExecuteNonQueryAsync(); + } + } + + private async Task DropSchemasAsync() + { + await using var connection = new NpgsqlConnection(ConnectionString); + await connection.OpenAsync(); + + await using var cmd = connection.CreateCommand(); + + // Drop tables first, then schemas + foreach (var schema in new[] { PoliciesSchema, GroupingsSchema, RolesSchema }) + { + cmd.CommandText = $"DROP TABLE IF EXISTS {schema}.casbin_rule CASCADE"; + await cmd.ExecuteNonQueryAsync(); + + cmd.CommandText = $"DROP SCHEMA IF EXISTS {schema} CASCADE"; + await cmd.ExecuteNonQueryAsync(); + } + } + + /// + /// Clears all policies from all schemas. Call before each test. + /// + public async Task ClearAllPoliciesAsync() + { + await using var connection = new NpgsqlConnection(ConnectionString); + await connection.OpenAsync(); + + await using var cmd = connection.CreateCommand(); + + foreach (var schema in new[] { PoliciesSchema, GroupingsSchema, RolesSchema }) + { + cmd.CommandText = $"DELETE FROM {schema}.casbin_rule"; + try + { + await cmd.ExecuteNonQueryAsync(); + } + catch (NpgsqlException) + { + // Table might not exist yet, ignore + } + } + } + + /// + /// Counts policies of a specific type in a schema + /// + public async Task CountPoliciesInSchemaAsync(string schemaName, string policyType = null) + { + await using var connection = new NpgsqlConnection(ConnectionString); + await connection.OpenAsync(); + + await using var cmd = connection.CreateCommand(); + if (policyType == null) + { + cmd.CommandText = $"SELECT COUNT(*) FROM {schemaName}.casbin_rule"; + } + else + { + cmd.CommandText = $"SELECT COUNT(*) FROM {schemaName}.casbin_rule WHERE ptype = @ptype"; + cmd.Parameters.AddWithValue("@ptype", policyType); + } + + try + { + var result = await cmd.ExecuteScalarAsync(); + return Convert.ToInt32(result); + } + catch (NpgsqlException) + { + // Table might not exist + return 0; + } + } + + /// + /// Inserts a policy directly into the database (for conflict simulation) + /// + public async Task InsertPolicyDirectlyAsync(string schemaName, string ptype, params string[] values) + { + await using var connection = new NpgsqlConnection(ConnectionString); + await connection.OpenAsync(); + + await using var cmd = connection.CreateCommand(); + cmd.CommandText = $@" + INSERT INTO {schemaName}.casbin_rule + (ptype, v0, v1, v2, v3, v4, v5) + VALUES (@ptype, @v0, @v1, @v2, @v3, @v4, @v5)"; + + cmd.Parameters.AddWithValue("@ptype", ptype); + for (int i = 0; i < 6; i++) + { + var value = i < values.Length ? values[i] : (object)DBNull.Value; + cmd.Parameters.AddWithValue($"@v{i}", value); + } + + await cmd.ExecuteNonQueryAsync(); + } + + /// + /// Drops a table in a schema (for failure simulation) + /// + public async Task DropTableAsync(string schemaName) + { + await using var connection = new NpgsqlConnection(ConnectionString); + await connection.OpenAsync(); + + await using var cmd = connection.CreateCommand(); + cmd.CommandText = $"DROP TABLE IF EXISTS {schemaName}.casbin_rule CASCADE"; + await cmd.ExecuteNonQueryAsync(); + } + } +} diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/TransactionIntegrityTests.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/TransactionIntegrityTests.cs new file mode 100644 index 0000000..eff099f --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/TransactionIntegrityTests.cs @@ -0,0 +1,576 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Casbin; +using Casbin.Persist.Adapter.EFCore.Entities; +using Npgsql; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Casbin.Persist.Adapter.EFCore.UnitTest.Integration +{ + /// + /// Integration tests verifying transaction integrity guarantees for multi-context scenarios. + /// These tests prove that shared DbConnection objects enable atomic transactions across multiple contexts. + /// + /// IMPORTANT: These tests are excluded from CI/CD via the "Integration" trait. + /// Run locally with: dotnet test --filter "Category=Integration" + /// + [Trait("Category", "Integration")] + [Collection("IntegrationTests")] + public class TransactionIntegrityTests : IClassFixture, IAsyncLifetime + { + private readonly TransactionIntegrityTestFixture _fixture; + private const string ModelPath = "examples/multi_context_model.conf"; + + public TransactionIntegrityTests(TransactionIntegrityTestFixture fixture) + { + _fixture = fixture; + } + + public Task InitializeAsync() => _fixture.ClearAllPoliciesAsync(); + + public Task DisposeAsync() => _fixture.RunMigrationsAsync(); + + #region Helper: Derived Context Classes + + /// + /// Derived context for policies schema + /// + public class TestCasbinDbContext1 : CasbinDbContext + { + public TestCasbinDbContext1( + DbContextOptions> options, + string schemaName, + string tableName) + : base(options, schemaName, tableName) + { + } + } + + /// + /// Derived context for groupings schema + /// + public class TestCasbinDbContext2 : CasbinDbContext + { + public TestCasbinDbContext2( + DbContextOptions> options, + string schemaName, + string tableName) + : base(options, schemaName, tableName) + { + } + } + + /// + /// Derived context for roles schema + /// + public class TestCasbinDbContext3 : CasbinDbContext + { + public TestCasbinDbContext3( + DbContextOptions> options, + string schemaName, + string tableName) + : base(options, schemaName, tableName) + { + } + } + + #endregion + + #region Helper Methods + + /// + /// Creates a three-way context provider routing policies to different schemas: + /// - p, p2, p3... → policies schema + /// - g → groupings schema + /// - g2, g3, g4... → roles schema + /// + private class ThreeWayPolicyTypeProvider : ICasbinDbContextProvider + { + private readonly DbContext _policyContext; + private readonly DbContext _groupingContext; + private readonly DbContext _roleContext; + private readonly System.Data.Common.DbConnection? _sharedConnection; + + public ThreeWayPolicyTypeProvider( + DbContext policyContext, + DbContext groupingContext, + DbContext roleContext, + System.Data.Common.DbConnection? sharedConnection) + { + _policyContext = policyContext ?? throw new ArgumentNullException(nameof(policyContext)); + _groupingContext = groupingContext ?? throw new ArgumentNullException(nameof(groupingContext)); + _roleContext = roleContext ?? throw new ArgumentNullException(nameof(roleContext)); + _sharedConnection = sharedConnection; + } + + public DbContext GetContextForPolicyType(string policyType) + { + if (string.IsNullOrEmpty(policyType)) + return _policyContext; + + // Route p policies to policy context + if (policyType.StartsWith("p", StringComparison.OrdinalIgnoreCase)) + return _policyContext; + + // Route g2+ to role context + if (policyType.StartsWith("g2", StringComparison.OrdinalIgnoreCase) || + policyType.StartsWith("g3", StringComparison.OrdinalIgnoreCase) || + policyType.StartsWith("g4", StringComparison.OrdinalIgnoreCase)) + return _roleContext; + + // Route g to grouping context + return _groupingContext; + } + + public IEnumerable GetAllContexts() + { + return new[] { _policyContext, _groupingContext, _roleContext }; + } + + public System.Data.Common.DbConnection? GetSharedConnection() + { + return _sharedConnection; + } + } + + /// + /// Creates an enforcer with three contexts sharing the same DbConnection + /// + private async Task<(Enforcer enforcer, NpgsqlConnection connection)> CreateEnforcerWithSharedConnectionAsync() + { + // Create ONE shared connection + var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + + // Create three contexts sharing the same connection + var policyContext = CreateContext(connection, TransactionIntegrityTestFixture.PoliciesSchema); + var groupingContext = CreateContext(connection, TransactionIntegrityTestFixture.GroupingsSchema); + var roleContext = CreateContext(connection, TransactionIntegrityTestFixture.RolesSchema); + + // Create provider routing policy types to appropriate contexts + var provider = new ThreeWayPolicyTypeProvider(policyContext, groupingContext, roleContext, connection); + + // Create adapter and enforcer + var adapter = new EFCoreAdapter(provider); + var enforcer = new Enforcer(ModelPath, adapter); + + await enforcer.LoadPolicyAsync(); + + return (enforcer, connection); + } + + /// + /// Creates an enforcer with three contexts using SEPARATE DbConnections (same connection string) + /// This is used to demonstrate non-atomic behavior when connections are not shared. + /// + private async Task CreateEnforcerWithSeparateConnectionsAsync() + { + // Create THREE separate connections with same connection string + var policyConnection = new NpgsqlConnection(_fixture.ConnectionString); + var groupingConnection = new NpgsqlConnection(_fixture.ConnectionString); + var roleConnection = new NpgsqlConnection(_fixture.ConnectionString); + + await policyConnection.OpenAsync(); + await groupingConnection.OpenAsync(); + await roleConnection.OpenAsync(); + + // Create three contexts with different connection objects + var policyContext = CreateContext(policyConnection, TransactionIntegrityTestFixture.PoliciesSchema); + var groupingContext = CreateContext(groupingConnection, TransactionIntegrityTestFixture.GroupingsSchema); + var roleContext = CreateContext(roleConnection, TransactionIntegrityTestFixture.RolesSchema); + + // Create provider routing policy types to appropriate contexts + // Pass null for shared connection since these contexts use separate connections + var provider = new ThreeWayPolicyTypeProvider(policyContext, groupingContext, roleContext, null); + + // Create adapter and enforcer + var adapter = new EFCoreAdapter(provider); + var enforcer = new Enforcer(ModelPath, adapter); + + await enforcer.LoadPolicyAsync(); + + return enforcer; + } + + private CasbinDbContext CreateContext(NpgsqlConnection connection, string schemaName) + { + var options = new DbContextOptionsBuilder>() + .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", schemaName)) + .Options; + + // Return appropriate derived context based on schema name + if (schemaName == TransactionIntegrityTestFixture.PoliciesSchema) + return new TestCasbinDbContext1(options, schemaName, "casbin_rule"); + else if (schemaName == TransactionIntegrityTestFixture.GroupingsSchema) + return new TestCasbinDbContext2(options, schemaName, "casbin_rule"); + else if (schemaName == TransactionIntegrityTestFixture.RolesSchema) + return new TestCasbinDbContext3(options, schemaName, "casbin_rule"); + else + throw new ArgumentException($"Unknown schema name: {schemaName}", nameof(schemaName)); + } + + #endregion + + #region Test 1: Atomicity - Happy Path + + [Fact] + public async Task SavePolicy_WithSharedConnection_ShouldWriteToAllContextsAtomically() + { + // Arrange + var (enforcer, connection) = await CreateEnforcerWithSharedConnectionAsync(); + + try + { + // Add policies that will route to different contexts + await enforcer.AddPolicyAsync("alice", "data1", "read"); // → policies schema (p) + await enforcer.AddGroupingPolicyAsync("alice", "admin"); // → groupings schema (g) + await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser"); // → roles schema (g2) + + // Act + await enforcer.SavePolicyAsync(); + + // Assert - Verify each schema has exactly the expected policies + var policiesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.PoliciesSchema, "p"); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.GroupingsSchema, "g"); + var rolesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.RolesSchema, "g2"); + + Assert.Equal(1, policiesCount); + Assert.Equal(1, groupingsCount); + Assert.Equal(1, rolesCount); + } + finally + { + await connection.CloseAsync(); + await connection.DisposeAsync(); + } + } + + #endregion + + #region Test 2: Connection Sharing Verification + + [Fact] + public async Task MultiContextSetup_WithSharedConnection_ShouldShareSamePhysicalConnection() + { + // Arrange + var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + + try + { + var policyContext = CreateContext(connection, TransactionIntegrityTestFixture.PoliciesSchema); + var groupingContext = CreateContext(connection, TransactionIntegrityTestFixture.GroupingsSchema); + var roleContext = CreateContext(connection, TransactionIntegrityTestFixture.RolesSchema); + + // Act & Assert - Verify reference equality (not just connection string equality) + var policyConn = policyContext.Database.GetDbConnection(); + var groupingConn = groupingContext.Database.GetDbConnection(); + var roleConn = roleContext.Database.GetDbConnection(); + + Assert.Same(connection, policyConn); + Assert.Same(connection, groupingConn); + Assert.Same(connection, roleConn); + Assert.Same(policyConn, groupingConn); + Assert.Same(groupingConn, roleConn); + } + finally + { + await connection.CloseAsync(); + await connection.DisposeAsync(); + } + } + + #endregion + + #region Test 3: Rollback - Missing Table (CRITICAL TEST) + + [Fact] + public async Task SavePolicy_WhenTableDroppedInOneContext_ShouldRollbackAllContexts() + { + // Arrange + var (enforcer, connection) = await CreateEnforcerWithSharedConnectionAsync(); + + try + { + // Disable AutoSave so policies stay in-memory until SavePolicyAsync() is called + enforcer.EnableAutoSave(false); + + // Add policies to all contexts (in memory only, AutoSave is OFF) + await enforcer.AddPolicyAsync("alice", "data1", "read"); // → policies schema + await enforcer.AddGroupingPolicyAsync("alice", "admin"); // → groupings schema + await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser"); // → roles schema + + // Drop table in roles schema AFTER policies are in memory but BEFORE SavePolicy + // This simulates a catastrophic failure scenario where database schema is inconsistent + await _fixture.DropTableAsync(TransactionIntegrityTestFixture.RolesSchema); + + Exception caughtException = null; + + // Act - Try to save, should throw due to missing table in roles schema + try + { + await enforcer.SavePolicyAsync(); + } + catch (Exception ex) + { + caughtException = ex; + } + + // Assert - Verify exception was thrown + Assert.NotNull(caughtException); + + // Recreate table for verification queries + await _fixture.RunMigrationsAsync(); + + // CRITICAL ASSERTION - Verify ZERO policies in all contexts (rollback successful) + // This proves that when one context fails, ALL contexts roll back atomically + var policiesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.PoliciesSchema, "p"); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.GroupingsSchema, "g"); + var rolesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.RolesSchema, "g2"); + + // Verify atomicity: All contexts rolled back (no partial commits) + Assert.Equal(0, policiesCount); // Should be 0 (rolled back) + Assert.Equal(0, groupingsCount); // Should be 0 (rolled back) + Assert.Equal(0, rolesCount); // Should be 0 (rolled back) + + // If we got here, atomicity is PROVEN! + } + finally + { + await connection.CloseAsync(); + await connection.DisposeAsync(); + + // Restore table for subsequent tests + await _fixture.RunMigrationsAsync(); + } + } + + #endregion + + #region Test 4: Rollback - Missing Table + + [Fact] + public async Task SavePolicy_WhenTableMissingInOneContext_ShouldRollbackAllContexts() + { + // Arrange + var (enforcer, connection) = await CreateEnforcerWithSharedConnectionAsync(); + + try + { + // Disable AutoSave so policies stay in-memory until SavePolicyAsync() is called + enforcer.EnableAutoSave(false); + + // Add policies to all contexts + await enforcer.AddPolicyAsync("alice", "data1", "read"); + await enforcer.AddGroupingPolicyAsync("alice", "admin"); + await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser"); + + // Drop table in roles schema AFTER enforcer is created + await _fixture.DropTableAsync(TransactionIntegrityTestFixture.RolesSchema); + + Exception caughtException = null; + + // Act - Try to save, should throw due to missing table + try + { + await enforcer.SavePolicyAsync(); + } + catch (Exception ex) + { + caughtException = ex; + } + + // Assert - Verify exception was thrown + Assert.NotNull(caughtException); + + // Recreate table for verification queries + await _fixture.RunMigrationsAsync(); + + // CRITICAL ASSERTION - Verify ZERO policies in all contexts (rollback successful) + var policiesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.PoliciesSchema, "p"); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.GroupingsSchema, "g"); + var rolesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.RolesSchema, "g2"); + + Assert.Equal(0, policiesCount); + Assert.Equal(0, groupingsCount); + Assert.Equal(0, rolesCount); + } + finally + { + await connection.CloseAsync(); + await connection.DisposeAsync(); + + // Restore table for subsequent tests + await _fixture.RunMigrationsAsync(); + } + } + + #endregion + + #region Test 5: Consistency Verification + + [Fact] + public async Task MultipleSaveOperations_WithSharedConnection_ShouldMaintainDataConsistency() + { + // Arrange + var (enforcer, connection) = await CreateEnforcerWithSharedConnectionAsync(); + + try + { + // Act - Perform multiple incremental saves + // Save 1 + await enforcer.AddPolicyAsync("alice", "data1", "read"); + await enforcer.AddGroupingPolicyAsync("alice", "admin"); + await enforcer.SavePolicyAsync(); + + // Save 2 + await enforcer.AddPolicyAsync("bob", "data2", "write"); + await enforcer.AddGroupingPolicyAsync("bob", "user"); + await enforcer.SavePolicyAsync(); + + // Save 3 + await enforcer.AddPolicyAsync("charlie", "data3", "read"); + await enforcer.AddGroupingPolicyAsync("charlie", "user"); + await enforcer.SavePolicyAsync(); + + // Assert - Verify all 6 policies present (3 p policies, 3 g policies) + var policiesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.PoliciesSchema, "p"); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.GroupingsSchema, "g"); + + Assert.Equal(3, policiesCount); + Assert.Equal(3, groupingsCount); + + // Verify all policies are enforced correctly + Assert.True(await enforcer.EnforceAsync("alice", "data1", "read")); + Assert.True(await enforcer.EnforceAsync("bob", "data2", "write")); + Assert.True(await enforcer.EnforceAsync("charlie", "data3", "read")); + } + finally + { + await connection.CloseAsync(); + await connection.DisposeAsync(); + } + } + + #endregion + + #region Test 6: Non-Atomic Behavior Without Shared Connection + + [Fact] + public async Task SavePolicy_WithSeparateConnections_ShouldNotBeAtomic() + { + // Arrange - Create enforcer with SEPARATE connection objects + var enforcer = await CreateEnforcerWithSeparateConnectionsAsync(); + + // Add policies to all contexts + await enforcer.AddPolicyAsync("alice", "data1", "read"); + await enforcer.AddGroupingPolicyAsync("alice", "admin"); + await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser"); + + // Drop table in roles schema to force failure + await _fixture.DropTableAsync(TransactionIntegrityTestFixture.RolesSchema); + + Exception caughtException = null; + + // Act - Try to save, should throw due to missing table in roles schema + try + { + await enforcer.SavePolicyAsync(); + } + catch (Exception ex) + { + caughtException = ex; + } + + // Assert - Verify exception was thrown + Assert.NotNull(caughtException); + + // Recreate table for verification queries + await _fixture.RunMigrationsAsync(); + + // CRITICAL ASSERTION - Verify policies WERE written to functioning contexts (NOT atomic!) + var policiesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.PoliciesSchema, "p"); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.GroupingsSchema, "g"); + var rolesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.RolesSchema, "g2"); + + // This test DOCUMENTS the non-atomic behavior without shared connections + // Policies and groupings were committed despite roles context failure + Assert.Equal(1, policiesCount); // Written (NOT rolled back) + Assert.Equal(1, groupingsCount); // Written (NOT rolled back) + Assert.Equal(0, rolesCount); // Failed to write (table dropped) + + // This proves that connection string matching alone is INSUFFICIENT for atomicity + // Must use shared DbConnection OBJECT for atomic transactions + } + + #endregion + + #region Test 7: Casbin In-Memory vs Database State + + [Fact] + public async Task SavePolicy_ShouldReflectDatabaseStateNotCasbinMemory() + { + // Arrange - Create first enforcer and save policies + var (enforcer1, connection1) = await CreateEnforcerWithSharedConnectionAsync(); + + try + { + await enforcer1.AddPolicyAsync("alice", "data1", "read"); + await enforcer1.AddGroupingPolicyAsync("alice", "admin"); + await enforcer1.SavePolicyAsync(); + } + finally + { + await connection1.CloseAsync(); + await connection1.DisposeAsync(); + } + + // Create second enforcer - loads existing policies from database + var (enforcer2, connection2) = await CreateEnforcerWithSharedConnectionAsync(); + + try + { + // Act - Try to add same policies again + var addedPolicy = await enforcer2.AddPolicyAsync("alice", "data1", "read"); + var addedGrouping = await enforcer2.AddGroupingPolicyAsync("alice", "admin"); + + // Assert - Casbin's in-memory check should prevent duplicates + Assert.False(addedPolicy); + Assert.False(addedGrouping); + + // Verify database unchanged (validates tests check database, not just Casbin memory) + var policiesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.PoliciesSchema, "p"); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.GroupingsSchema, "g"); + + Assert.Equal(1, policiesCount); + Assert.Equal(1, groupingsCount); + } + finally + { + await connection2.CloseAsync(); + await connection2.DisposeAsync(); + } + } + + #endregion + } +} diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/MultiContextTest.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/MultiContextTest.cs new file mode 100644 index 0000000..78e0977 --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/MultiContextTest.cs @@ -0,0 +1,495 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Casbin.Persist.Adapter.EFCore.Entities; +using Casbin.Persist.Adapter.EFCore.UnitTest.Extensions; +using Casbin.Persist.Adapter.EFCore.UnitTest.Fixtures; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Casbin.Persist.Adapter.EFCore.UnitTest +{ + /// + /// Tests for multi-context functionality where different policy types + /// can be stored in separate database contexts/tables/schemas. + /// + public class MultiContextTest : TestUtil, + IClassFixture, + IClassFixture + { + private readonly ModelProvideFixture _modelProvideFixture; + private readonly MultiContextProviderFixture _multiContextProviderFixture; + + public MultiContextTest( + ModelProvideFixture modelProvideFixture, + MultiContextProviderFixture multiContextProviderFixture) + { + _modelProvideFixture = modelProvideFixture; + _multiContextProviderFixture = multiContextProviderFixture; + } + + [Fact] + public void TestMultiContextAddPolicy() + { + // Arrange + var provider = _multiContextProviderFixture.GetMultiContextProvider("AddPolicy"); + var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("AddPolicy"); + + policyContext.Clear(); + groupingContext.Clear(); + + var adapter = new EFCoreAdapter(provider); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + // Act - Add policy rules (should go to policy context) + enforcer.AddPolicy("alice", "data1", "read"); + enforcer.AddPolicy("bob", "data2", "write"); + + // Add grouping rules (should go to grouping context) + enforcer.AddGroupingPolicy("alice", "admin"); + + // Assert - Verify policies are in the correct contexts + Assert.Equal(2, policyContext.Policies.Count()); + Assert.Equal(1, groupingContext.Policies.Count()); + + // Verify policy data + var alicePolicy = policyContext.Policies.FirstOrDefault(p => p.Value1 == "alice"); + Assert.NotNull(alicePolicy); + Assert.Equal("p", alicePolicy.Type); + Assert.Equal("data1", alicePolicy.Value2); + Assert.Equal("read", alicePolicy.Value3); + + // Verify grouping data + var aliceGrouping = groupingContext.Policies.FirstOrDefault(p => p.Value1 == "alice"); + Assert.NotNull(aliceGrouping); + Assert.Equal("g", aliceGrouping.Type); + Assert.Equal("admin", aliceGrouping.Value2); + } + + [Fact] + public async Task TestMultiContextAddPolicyAsync() + { + // Arrange + var provider = _multiContextProviderFixture.GetMultiContextProvider("AddPolicyAsync"); + var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("AddPolicyAsync"); + + policyContext.Clear(); + groupingContext.Clear(); + + var adapter = new EFCoreAdapter(provider); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + // Act + await enforcer.AddPolicyAsync("alice", "data1", "read"); + await enforcer.AddPolicyAsync("bob", "data2", "write"); + await enforcer.AddGroupingPolicyAsync("alice", "admin"); + + // Assert + Assert.Equal(2, await policyContext.Policies.CountAsync()); + Assert.Equal(1, await groupingContext.Policies.CountAsync()); + } + + [Fact] + public void TestMultiContextRemovePolicy() + { + // Arrange + var provider = _multiContextProviderFixture.GetMultiContextProvider("RemovePolicy"); + var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("RemovePolicy"); + + policyContext.Clear(); + groupingContext.Clear(); + + // Pre-populate data + policyContext.Policies.Add(new EFCorePersistPolicy + { + Type = "p", + Value1 = "alice", + Value2 = "data1", + Value3 = "read" + }); + policyContext.SaveChanges(); + + groupingContext.Policies.Add(new EFCorePersistPolicy + { + Type = "g", + Value1 = "alice", + Value2 = "admin" + }); + groupingContext.SaveChanges(); + + var adapter = new EFCoreAdapter(provider); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + enforcer.LoadPolicy(); + + // Act + enforcer.RemovePolicy("alice", "data1", "read"); + enforcer.RemoveGroupingPolicy("alice", "admin"); + + // Assert + Assert.Equal(0, policyContext.Policies.Count()); + Assert.Equal(0, groupingContext.Policies.Count()); + } + + [Fact] + public void TestMultiContextLoadPolicy() + { + // Arrange + var provider = _multiContextProviderFixture.GetMultiContextProvider("LoadPolicy"); + var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("LoadPolicy"); + + policyContext.Clear(); + groupingContext.Clear(); + + // Add test data to policy context + policyContext.Policies.AddRange(new[] + { + new EFCorePersistPolicy { Type = "p", Value1 = "alice", Value2 = "data1", Value3 = "read" }, + new EFCorePersistPolicy { Type = "p", Value1 = "bob", Value2 = "data2", Value3 = "write" } + }); + policyContext.SaveChanges(); + + // Add test data to grouping context + groupingContext.Policies.AddRange(new[] + { + new EFCorePersistPolicy { Type = "g", Value1 = "alice", Value2 = "admin" }, + new EFCorePersistPolicy { Type = "g", Value1 = "bob", Value2 = "user" } + }); + groupingContext.SaveChanges(); + + var adapter = new EFCoreAdapter(provider); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + // Act + enforcer.LoadPolicy(); + + // Assert - Verify all policies loaded from both contexts + TestGetPolicy(enforcer, AsList( + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write") + )); + + TestGetGroupingPolicy(enforcer, AsList( + AsList("alice", "admin"), + AsList("bob", "user") + )); + } + + [Fact] + public async Task TestMultiContextLoadPolicyAsync() + { + // Arrange + var provider = _multiContextProviderFixture.GetMultiContextProvider("LoadPolicyAsync"); + var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("LoadPolicyAsync"); + + policyContext.Clear(); + groupingContext.Clear(); + + policyContext.Policies.AddRange(new[] + { + new EFCorePersistPolicy { Type = "p", Value1 = "alice", Value2 = "data1", Value3 = "read" } + }); + await policyContext.SaveChangesAsync(); + + groupingContext.Policies.Add(new EFCorePersistPolicy { Type = "g", Value1 = "alice", Value2 = "admin" }); + await groupingContext.SaveChangesAsync(); + + var adapter = new EFCoreAdapter(provider); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + // Act + await enforcer.LoadPolicyAsync(); + + // Assert + Assert.Single(enforcer.GetPolicy()); + Assert.Single(enforcer.GetGroupingPolicy()); + } + + [Fact] + public void TestMultiContextSavePolicy() + { + // Arrange + var provider = _multiContextProviderFixture.GetMultiContextProvider("SavePolicy"); + var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("SavePolicy"); + + policyContext.Clear(); + groupingContext.Clear(); + + var adapter = new EFCoreAdapter(provider); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + // Add policies via enforcer + enforcer.AddPolicy("alice", "data1", "read"); + enforcer.AddPolicy("bob", "data2", "write"); + enforcer.AddGroupingPolicy("alice", "admin"); + + // Act - Save should distribute policies to correct contexts + enforcer.SavePolicy(); + + // Assert - Verify data is in correct contexts + Assert.Equal(2, policyContext.Policies.Count()); + Assert.Equal(1, groupingContext.Policies.Count()); + + // Verify we can reload from both contexts + var newEnforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + newEnforcer.LoadPolicy(); + + TestGetPolicy(newEnforcer, AsList( + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write") + )); + + TestGetGroupingPolicy(newEnforcer, AsList( + AsList("alice", "admin") + )); + } + + [Fact] + public async Task TestMultiContextSavePolicyAsync() + { + // Arrange + var provider = _multiContextProviderFixture.GetMultiContextProvider("SavePolicyAsync"); + var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("SavePolicyAsync"); + + policyContext.Clear(); + groupingContext.Clear(); + + var adapter = new EFCoreAdapter(provider); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + enforcer.AddPolicy("alice", "data1", "read"); + enforcer.AddGroupingPolicy("alice", "admin"); + + // Act + await enforcer.SavePolicyAsync(); + + // Assert + Assert.Equal(1, await policyContext.Policies.CountAsync()); + Assert.Equal(1, await groupingContext.Policies.CountAsync()); + } + + [Fact] + public void TestMultiContextBatchOperations() + { + // Arrange + var provider = _multiContextProviderFixture.GetMultiContextProvider("BatchOperations"); + var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("BatchOperations"); + + policyContext.Clear(); + groupingContext.Clear(); + + var adapter = new EFCoreAdapter(provider); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + // Act - Add multiple policies at once + enforcer.AddPolicies(new[] + { + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write"), + AsList("charlie", "data3", "read") + }); + + // Assert + Assert.Equal(3, policyContext.Policies.Count()); + + // Act - Remove multiple policies + enforcer.RemovePolicies(new[] + { + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write") + }); + + // Assert + Assert.Equal(1, policyContext.Policies.Count()); + Assert.Equal("charlie", policyContext.Policies.First().Value1); + } + + [Fact] + public void TestMultiContextLoadFilteredPolicy() + { + // Arrange + var provider = _multiContextProviderFixture.GetMultiContextProvider("LoadFilteredPolicy"); + var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("LoadFilteredPolicy"); + + policyContext.Clear(); + groupingContext.Clear(); + + // Add multiple policies + policyContext.Policies.AddRange(new[] + { + new EFCorePersistPolicy { Type = "p", Value1 = "alice", Value2 = "data1", Value3 = "read" }, + new EFCorePersistPolicy { Type = "p", Value1 = "bob", Value2 = "data2", Value3 = "write" } + }); + policyContext.SaveChanges(); + + groupingContext.Policies.Add(new EFCorePersistPolicy { Type = "g", Value1 = "alice", Value2 = "admin" }); + groupingContext.SaveChanges(); + + var adapter = new EFCoreAdapter(provider); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + // Act - Load only alice's policies + enforcer.LoadFilteredPolicy(new Filter + { + P = AsList("alice", "", "") + }); + + // Assert + TestGetPolicy(enforcer, AsList( + AsList("alice", "data1", "read") + )); + + // Bob's policy should not be loaded + Assert.DoesNotContain(enforcer.GetPolicy(), p => p.Contains("bob")); + } + + /// + /// Verifies that UpdatePolicy operations work across multiple contexts without throwing exceptions. + /// + /// NOTE: This is NOT a transaction rollback test. This test uses separate SQLite database files + /// (policy.db and grouping.db), making atomic cross-context transactions impossible. + /// + /// For actual transaction integrity and rollback verification across multiple contexts, + /// see Integration/TransactionIntegrityTests.cs (PostgreSQL tests with shared connections). + /// + [Fact] + public void TestMultiContextUpdatePolicyNoException() + { + // Arrange + var provider = _multiContextProviderFixture.GetMultiContextProvider("UpdatePolicyNoException"); + var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("UpdatePolicyNoException"); + + policyContext.Clear(); + groupingContext.Clear(); + + var adapter = new EFCoreAdapter(provider); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + // Add initial data + enforcer.AddPolicy("alice", "data1", "read"); + enforcer.AddGroupingPolicy("alice", "admin"); + + var initialPolicyCount = policyContext.Policies.Count(); + var initialGroupingCount = groupingContext.Policies.Count(); + + // Act & Assert - UpdatePolicy should complete without throwing exceptions + enforcer.UpdatePolicy( + AsList("alice", "data1", "read"), + AsList("alice", "data1", "write") + ); + + // Verify the update was applied successfully + Assert.True(enforcer.HasPolicy("alice", "data1", "write")); + Assert.False(enforcer.HasPolicy("alice", "data1", "read")); + } + + [Fact] + public void TestMultiContextProviderGetAllContexts() + { + // Arrange + var provider = _multiContextProviderFixture.GetMultiContextProvider("GetAllContexts"); + + // Act + var contexts = provider.GetAllContexts().ToList(); + + // Assert + Assert.Equal(2, contexts.Count); + Assert.All(contexts, ctx => Assert.NotNull(ctx)); + } + + [Fact] + public void TestMultiContextProviderGetContextForPolicyType() + { + // Arrange + var provider = _multiContextProviderFixture.GetMultiContextProvider("GetContextForType"); + + // Act & Assert + var pContext = provider.GetContextForPolicyType("p"); + var p2Context = provider.GetContextForPolicyType("p2"); + var gContext = provider.GetContextForPolicyType("g"); + var g2Context = provider.GetContextForPolicyType("g2"); + + // All 'p' types should route to same context + Assert.Same(pContext, p2Context); + + // All 'g' types should route to same context + Assert.Same(gContext, g2Context); + + // 'p' and 'g' types should route to different contexts + Assert.NotSame(pContext, gContext); + } + + [Fact] + public void TestDbSetCachingByPolicyType() + { + // This test verifies that the DbSet cache uses (context, policyType) as the composite key + // rather than just context. This prevents the bug where different policy types would + // incorrectly share the same cached DbSet. + + // Arrange + var provider = _multiContextProviderFixture.GetMultiContextProvider("DbSetCaching"); + var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("DbSetCaching"); + + policyContext.Clear(); + groupingContext.Clear(); + + // Create a custom adapter that tracks GetCasbinRuleDbSet calls + var callTracker = new Dictionary(); + var adapter = new DbSetCachingTestAdapter(provider, callTracker); + var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); + + // Act - Add policies of different types + enforcer.AddPolicy("alice", "data1", "read"); // Type 'p' - first call should invoke GetCasbinRuleDbSet + enforcer.AddPolicy("bob", "data2", "write"); // Type 'p' - should use cached DbSet + enforcer.AddGroupingPolicy("alice", "admin"); // Type 'g' - different type, should invoke GetCasbinRuleDbSet + enforcer.AddGroupingPolicy("bob", "user"); // Type 'g' - should use cached DbSet + + // Assert - Verify GetCasbinRuleDbSet was called once per unique (context, policyType) combination + // If the cache key was only 'context', it would be called once and return wrong DbSet for 'g' + Assert.Equal(1, callTracker["p"]); // Called once for 'p', then cached + Assert.Equal(1, callTracker["g"]); // Called once for 'g', then cached + + // Verify data went to correct contexts + Assert.Equal(2, policyContext.Policies.Count()); + Assert.Equal(2, groupingContext.Policies.Count()); + + // Verify policy types are correct + Assert.All(policyContext.Policies, p => Assert.Equal("p", p.Type)); + Assert.All(groupingContext.Policies, g => Assert.Equal("g", g.Type)); + } + } + + /// + /// Test adapter that tracks how many times GetCasbinRuleDbSet is called per policy type. + /// This is used to verify the DbSet caching behavior. + /// + internal class DbSetCachingTestAdapter : EFCoreAdapter + { + private readonly Dictionary _callTracker; + + public DbSetCachingTestAdapter( + ICasbinDbContextProvider contextProvider, + Dictionary callTracker) + : base(contextProvider) + { + _callTracker = callTracker; + } + + protected override DbSet> GetCasbinRuleDbSet(DbContext dbContext, string policyType) + { + // Track that this method was called for this policy type + // Only track non-null policy types (null is used for general operations) + if (policyType != null) + { + if (!_callTracker.ContainsKey(policyType)) + { + _callTracker[policyType] = 0; + } + _callTracker[policyType]++; + } + + // Call base implementation to get the actual DbSet + return base.GetCasbinRuleDbSet(dbContext, policyType); + } + } +} diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/SpecialPolicyTest.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/SpecialPolicyTest.cs index e3253c4..a7b5cc2 100644 --- a/Casbin.Persist.Adapter.EFCore.UnitTest/SpecialPolicyTest.cs +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/SpecialPolicyTest.cs @@ -6,13 +6,13 @@ namespace Casbin.Persist.Adapter.EFCore.UnitTest { - public class SpecialPolicyTest : TestUtil, IClassFixture, + public class PolicyEdgeCasesTest : TestUtil, IClassFixture, IClassFixture { private readonly ModelProvideFixture _modelProvideFixture; private readonly DbContextProviderFixture _dbContextProviderFixture; - public SpecialPolicyTest(ModelProvideFixture modelProvideFixture, + public PolicyEdgeCasesTest(ModelProvideFixture modelProvideFixture, DbContextProviderFixture dbContextProviderFixture) { _modelProvideFixture = modelProvideFixture; diff --git a/Casbin.Persist.Adapter.EFCore/Casbin.Persist.Adapter.EFCore.csproj b/Casbin.Persist.Adapter.EFCore/Casbin.Persist.Adapter.EFCore.csproj index 1df76e6..78e04d3 100644 --- a/Casbin.Persist.Adapter.EFCore/Casbin.Persist.Adapter.EFCore.csproj +++ b/Casbin.Persist.Adapter.EFCore/Casbin.Persist.Adapter.EFCore.csproj @@ -17,7 +17,7 @@ - + diff --git a/Casbin.Persist.Adapter.EFCore/CasbinDbContext.cs b/Casbin.Persist.Adapter.EFCore/CasbinDbContext.cs index 5a8c910..9389cef 100644 --- a/Casbin.Persist.Adapter.EFCore/CasbinDbContext.cs +++ b/Casbin.Persist.Adapter.EFCore/CasbinDbContext.cs @@ -1,4 +1,6 @@ using System; +using System.Threading; +using System.Threading.Tasks; using Casbin.Persist.Adapter.EFCore.Entities; using Microsoft.EntityFrameworkCore; @@ -54,5 +56,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } + } } diff --git a/Casbin.Persist.Adapter.EFCore/DefaultPersistPolicyEntityTypeConfiguration.cs b/Casbin.Persist.Adapter.EFCore/DefaultPersistPolicyEntityTypeConfiguration.cs index 9d41921..5853150 100644 --- a/Casbin.Persist.Adapter.EFCore/DefaultPersistPolicyEntityTypeConfiguration.cs +++ b/Casbin.Persist.Adapter.EFCore/DefaultPersistPolicyEntityTypeConfiguration.cs @@ -28,14 +28,14 @@ public virtual void Configure(EntityTypeBuilder> build builder.Property(p => p.Value4).HasColumnName("v3"); builder.Property(p => p.Value5).HasColumnName("v4"); builder.Property(p => p.Value6).HasColumnName("v5"); - builder.Ignore(p => p.Value7); - builder.Ignore(p => p.Value8); - builder.Ignore(p => p.Value9); - builder.Ignore(p => p.Value10); - builder.Ignore(p => p.Value11); - builder.Ignore(p => p.Value12); - builder.Ignore(p => p.Value13); - builder.Ignore(p => p.Value14); + builder.Property(p => p.Value7).HasColumnName("v6"); + builder.Property(p => p.Value8).HasColumnName("v7"); + builder.Property(p => p.Value9).HasColumnName("v8"); + builder.Property(p => p.Value10).HasColumnName("v9"); + builder.Property(p => p.Value11).HasColumnName("v10"); + builder.Property(p => p.Value12).HasColumnName("v11"); + builder.Property(p => p.Value13).HasColumnName("v12"); + builder.Property(p => p.Value14).HasColumnName("v13"); builder.HasIndex(p => p.Type); builder.HasIndex(p => p.Value1); diff --git a/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.Internal.cs b/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.Internal.cs index 1a05f34..e916ade 100644 --- a/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.Internal.cs +++ b/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.Internal.cs @@ -4,6 +4,8 @@ using System.Threading.Tasks; using Casbin.Model; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; // ReSharper disable InconsistentNaming // ReSharper disable MemberCanBeProtected.Global @@ -18,82 +20,191 @@ public partial class EFCoreAdapter : IAdapter, { private void InternalAddPolicy(string section, string policyType, IPolicyValues values) { + var context = GetContextForPolicyType(policyType); + InternalAddPolicy(context, section, policyType, values); + } + + private void InternalAddPolicy(DbContext context, string section, string policyType, IPolicyValues values) + { + var dbSet = GetCasbinRuleDbSetForPolicyType(context, policyType); var persistPolicy = PersistPolicy.Create(section, policyType, values); persistPolicy = OnAddPolicy(section, policyType, values, persistPolicy); - PersistPolicies.Add(persistPolicy); + dbSet.Add(persistPolicy); } private async ValueTask InternalAddPolicyAsync(string section, string policyType, IPolicyValues values) { + var context = GetContextForPolicyType(policyType); + await InternalAddPolicyAsync(context, section, policyType, values); + } + + private async ValueTask InternalAddPolicyAsync(DbContext context, string section, string policyType, IPolicyValues values) + { + var dbSet = GetCasbinRuleDbSetForPolicyType(context, policyType); var persistPolicy = PersistPolicy.Create(section, policyType, values); persistPolicy = OnAddPolicy(section, policyType, values, persistPolicy); - await PersistPolicies.AddAsync(persistPolicy); + await dbSet.AddAsync(persistPolicy); } private void InternalUpdatePolicy(string section, string policyType, IPolicyValues oldValues , IPolicyValues newValues) { - InternalRemovePolicy(section, policyType, oldValues); - InternalAddPolicy(section, policyType, newValues); + var context = GetContextForPolicyType(policyType); + InternalUpdatePolicy(context, section, policyType, oldValues, newValues); + } + + private void InternalUpdatePolicy(DbContext context, string section, string policyType, IPolicyValues oldValues , IPolicyValues newValues) + { + InternalRemovePolicy(context, section, policyType, oldValues); + InternalAddPolicy(context, section, policyType, newValues); } private ValueTask InternalUpdatePolicyAsync(string section, string policyType, IPolicyValues oldValues , IPolicyValues newValues) { - InternalRemovePolicy(section, policyType, oldValues); - return InternalAddPolicyAsync(section, policyType, newValues); + var context = GetContextForPolicyType(policyType); + return InternalUpdatePolicyAsync(context, section, policyType, oldValues, newValues); + } + + private async ValueTask InternalUpdatePolicyAsync(DbContext context, string section, string policyType, IPolicyValues oldValues , IPolicyValues newValues) + { + InternalRemovePolicy(context, section, policyType, oldValues); + await InternalAddPolicyAsync(context, section, policyType, newValues); } private void InternalAddPolicies(string section, string policyType, IReadOnlyList valuesList) { + var context = GetContextForPolicyType(policyType); + InternalAddPolicies(context, section, policyType, valuesList); + } + + private void InternalAddPolicies(DbContext context, string section, string policyType, IReadOnlyList valuesList) + { + var dbSet = GetCasbinRuleDbSetForPolicyType(context, policyType); var persistPolicies = valuesList. Select(v => PersistPolicy.Create(section, policyType, v)); persistPolicies = OnAddPolicies(section, policyType, valuesList, persistPolicies); - PersistPolicies.AddRange(persistPolicies); + dbSet.AddRange(persistPolicies); } private async ValueTask InternalAddPoliciesAsync(string section, string policyType, IReadOnlyList valuesList) { - var persistPolicies = valuesList.Select(v => + var context = GetContextForPolicyType(policyType); + await InternalAddPoliciesAsync(context, section, policyType, valuesList); + } + + private async ValueTask InternalAddPoliciesAsync(DbContext context, string section, string policyType, IReadOnlyList valuesList) + { + var dbSet = GetCasbinRuleDbSetForPolicyType(context, policyType); + var persistPolicies = valuesList.Select(v => PersistPolicy.Create(section, policyType, v)); persistPolicies = OnAddPolicies(section, policyType, valuesList, persistPolicies); - await PersistPolicies.AddRangeAsync(persistPolicies); + await dbSet.AddRangeAsync(persistPolicies); } private void InternalUpdatePolicies(string section, string policyType, IReadOnlyList oldValuesList, IReadOnlyList newValuesList) { - InternalRemovePolicies(section, policyType, oldValuesList); - InternalAddPolicies(section, policyType, newValuesList); + var context = GetContextForPolicyType(policyType); + InternalUpdatePolicies(context, section, policyType, oldValuesList, newValuesList); + } + + private void InternalUpdatePolicies(DbContext context, string section, string policyType, IReadOnlyList oldValuesList, IReadOnlyList newValuesList) + { + InternalRemovePolicies(context, section, policyType, oldValuesList); + InternalAddPolicies(context, section, policyType, newValuesList); } private ValueTask InternalUpdatePoliciesAsync(string section, string policyType, IReadOnlyList oldValuesList, IReadOnlyList newValuesList) { - InternalRemovePolicies(section, policyType, oldValuesList); - return InternalAddPoliciesAsync(section, policyType, newValuesList); + var context = GetContextForPolicyType(policyType); + return InternalUpdatePoliciesAsync(context, section, policyType, oldValuesList, newValuesList); + } + + private async ValueTask InternalUpdatePoliciesAsync(DbContext context, string section, string policyType, IReadOnlyList oldValuesList, IReadOnlyList newValuesList) + { + InternalRemovePolicies(context, section, policyType, oldValuesList); + await InternalAddPoliciesAsync(context, section, policyType, newValuesList); } private void InternalRemovePolicy(string section, string policyType, IPolicyValues values) { - RemoveFilteredPolicy(section, policyType, 0, values); + var context = GetContextForPolicyType(policyType); + InternalRemovePolicy(context, section, policyType, values); } - + + private void InternalRemovePolicy(DbContext context, string section, string policyType, IPolicyValues values) + { + InternalRemoveFilteredPolicy(context, section, policyType, 0, values); + } + private void InternalRemovePolicies(string section, string policyType, IReadOnlyList valuesList) + { + var context = GetContextForPolicyType(policyType); + InternalRemovePolicies(context, section, policyType, valuesList); + } + + private void InternalRemovePolicies(DbContext context, string section, string policyType, IReadOnlyList valuesList) { foreach (var value in valuesList) { - InternalRemovePolicy(section, policyType, value); + InternalRemovePolicy(context, section, policyType, value); } } private void InternalRemoveFilteredPolicy(string section, string policyType, int fieldIndex, IPolicyValues fieldValues) { + var context = GetContextForPolicyType(policyType); + InternalRemoveFilteredPolicy(context, section, policyType, fieldIndex, fieldValues); + } + + private void InternalRemoveFilteredPolicy(DbContext context, string section, string policyType, int fieldIndex, IPolicyValues fieldValues) + { + var dbSet = GetCasbinRuleDbSetForPolicyType(context, policyType); var filter = new PolicyFilter(policyType, fieldIndex, fieldValues); - var persistPolicies = filter.Apply(PersistPolicies); + var persistPolicies = filter.Apply(dbSet); persistPolicies = OnRemoveFilteredPolicy(section, policyType, fieldIndex, fieldValues, persistPolicies); - PersistPolicies.RemoveRange(persistPolicies); + dbSet.RemoveRange(persistPolicies); } + #region Helper methods + + /// + /// Gets or caches the DbSet for a specific context and policy type + /// + private DbSet GetCasbinRuleDbSetForPolicyType(DbContext context, string policyType) + { + var key = (context, policyType); + if (!_persistPoliciesByContext.TryGetValue(key, out var dbSet)) + { + dbSet = GetCasbinRuleDbSet(context, policyType); + _persistPoliciesByContext[key] = dbSet; + } + return dbSet; + } + + /// + /// Gets the context responsible for handling a specific policy type + /// + private DbContext GetContextForPolicyType(string policyType) + { + return _contextProvider.GetContextForPolicyType(policyType); + } + + #endregion + #region virtual method + /// + /// Gets the DbSet for policies from the specified context (backward compatible) + /// + [Obsolete("Use GetCasbinRuleDbSet(DbContext, string) instead. This method will be removed in a future major version.", false)] protected virtual DbSet GetCasbinRuleDbSet(TDbContext dbContext) + { + return GetCasbinRuleDbSet((DbContext)dbContext, null); + } + + /// + /// Gets the DbSet for policies from the specified context with optional policy type routing + /// + protected virtual DbSet GetCasbinRuleDbSet(DbContext dbContext, string policyType) { return dbContext.Set(); } diff --git a/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs b/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs index 3994b18..984830a 100644 --- a/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs +++ b/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs @@ -3,6 +3,8 @@ using System.Linq; using System; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; using System.Threading.Tasks; using Casbin.Persist.Adapter.EFCore.Extensions; using Casbin.Persist.Adapter.EFCore.Entities; @@ -24,6 +26,11 @@ public EFCoreAdapter(IServiceProvider serviceProvider) : base(serviceProvider) { } + + public EFCoreAdapter(ICasbinDbContextProvider contextProvider) : base(contextProvider) + { + + } } public class EFCoreAdapter : EFCoreAdapter> @@ -41,57 +48,117 @@ public EFCoreAdapter(IServiceProvider serviceProvider) : base(serviceProvider) { } + + public EFCoreAdapter(ICasbinDbContextProvider contextProvider) : base(contextProvider) + { + + } } - public partial class EFCoreAdapter : IAdapter, IFilteredAdapter + /// + /// Entity Framework Core adapter for Casbin authorization library. + /// Supports both single-context and multi-context scenarios for policy storage. + /// + /// + /// Performance Considerations: + /// + /// The adapter caches DbSet instances per (DbContext, policyType) combination in an + /// internal dictionary for performance. This cache grows to at most (N contexts × M policy types) + /// entries, typically 2-8 entries in practice. Memory overhead is negligible (~224 bytes typical, + /// ~3.5 KB worst-case). The cache lifetime matches the adapter instance lifetime. + /// + /// Lifecycle: + /// + /// In multi-context scenarios, ensure DbContext instances live at least as long as the + /// adapter instance. Typical usage patterns (singleton DI registration or test fixtures) + /// naturally satisfy this requirement. + /// + /// + /// The type of the policy identifier (e.g., int, Guid, string) + /// The entity type for persisting policies + /// The DbContext type + public partial class EFCoreAdapter : IAdapter, IFilteredAdapter where TDbContext : DbContext where TPersistPolicy : class, IEFCorePersistPolicy, new() where TKey : IEquatable { private DbSet _persistPolicies; - private readonly IServiceProvider _serviceProvider; - private readonly bool _useServiceProvider; - - protected TDbContext DbContext { get; private set; } - protected DbSet PersistPolicies => _persistPolicies ??= GetCasbinRuleDbSet(GetOrResolveDbContext()); + private readonly ICasbinDbContextProvider _contextProvider; + private readonly Dictionary<(DbContext context, string policyType), DbSet> _persistPoliciesByContext; + + protected TDbContext DbContext { get; } + protected DbSet PersistPolicies => _persistPolicies ??= GetCasbinRuleDbSet(DbContext); + /// + /// Creates adapter with single context (backward compatible) + /// public EFCoreAdapter(TDbContext context) { DbContext = context ?? throw new ArgumentNullException(nameof(context)); - _useServiceProvider = false; + _contextProvider = new SingleContextProvider(context); + _persistPoliciesByContext = new Dictionary<(DbContext context, string policyType), DbSet>(); } + /// + /// Creates adapter with single context resolved from IServiceProvider (for DI scenarios) + /// public EFCoreAdapter(IServiceProvider serviceProvider) { - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - _useServiceProvider = true; + if (serviceProvider == null) throw new ArgumentNullException(nameof(serviceProvider)); + _contextProvider = new ServiceProviderContextProvider(serviceProvider); + _persistPoliciesByContext = new Dictionary<(DbContext context, string policyType), DbSet>(); + + // Eagerly resolve to set DbContext property for backward compatibility + DbContext = (TDbContext)_contextProvider.GetAllContexts().First(); } - private TDbContext GetOrResolveDbContext() + /// + /// Creates adapter with custom context provider for multi-context scenarios + /// + public EFCoreAdapter(ICasbinDbContextProvider contextProvider) { - if (_useServiceProvider) - { - return _serviceProvider.GetService(typeof(TDbContext)) as TDbContext - ?? throw new InvalidOperationException($"Unable to resolve service for type '{typeof(TDbContext)}' from IServiceProvider."); - } - return DbContext; + _contextProvider = contextProvider ?? throw new ArgumentNullException(nameof(contextProvider)); + _persistPoliciesByContext = new Dictionary<(DbContext context, string policyType), DbSet>(); + + // In multi-context mode, try to set DbContext for backward compatibility + // If there's exactly one context, use it; otherwise leave null + var contexts = _contextProvider.GetAllContexts().Distinct().ToList(); + DbContext = contexts.Count == 1 ? (TDbContext)contexts[0] : null; } #region Load policy public virtual void LoadPolicy(IPolicyStore store) { - var casbinRules = PersistPolicies.AsNoTracking(); - casbinRules = OnLoadPolicy(store, casbinRules); - store.LoadPolicyFromPersistPolicy(casbinRules.ToList()); + var allPolicies = new List(); + + // Load from each unique context + foreach (var context in _contextProvider.GetAllContexts().Distinct()) + { + var dbSet = GetCasbinRuleDbSet(context, null); + var policies = dbSet.AsNoTracking().ToList(); + allPolicies.AddRange(policies); + } + + var filteredPolicies = OnLoadPolicy(store, allPolicies.AsQueryable()); + store.LoadPolicyFromPersistPolicy(filteredPolicies.ToList()); IsFiltered = false; } public virtual async Task LoadPolicyAsync(IPolicyStore store) { - var casbinRules = PersistPolicies.AsNoTracking(); - casbinRules = OnLoadPolicy(store, casbinRules); - store.LoadPolicyFromPersistPolicy(await casbinRules.ToListAsync()); + var allPolicies = new List(); + + // Load from each unique context + foreach (var context in _contextProvider.GetAllContexts().Distinct()) + { + var dbSet = GetCasbinRuleDbSet(context, null); + var policies = await dbSet.AsNoTracking().ToListAsync(); + allPolicies.AddRange(policies); + } + + var filteredPolicies = OnLoadPolicy(store, allPolicies.AsQueryable()); + store.LoadPolicyFromPersistPolicy(filteredPolicies.ToList()); IsFiltered = false; } @@ -101,7 +168,6 @@ public virtual async Task LoadPolicyAsync(IPolicyStore store) public virtual void SavePolicy(IPolicyStore store) { - var dbContext = GetOrResolveDbContext(); var persistPolicies = new List(); persistPolicies.ReadPolicyFromCasbinModel(store); @@ -110,18 +176,213 @@ public virtual void SavePolicy(IPolicyStore store) return; } - var existRule = PersistPolicies.ToList(); - PersistPolicies.RemoveRange(existRule); - dbContext.SaveChanges(); + // Group policies by their target context + var policiesByContext = persistPolicies + .GroupBy(p => _contextProvider.GetContextForPolicyType(p.Type)) + .ToList(); + + var contexts = _contextProvider.GetAllContexts().Distinct().ToList(); + + // Check if we can use a shared transaction (all contexts use same connection) + if (contexts.Count == 1 || CanShareTransaction(contexts)) + { + // Single context or shared connection - use single transaction + SavePolicyWithSharedTransaction(store, contexts, policiesByContext); + } + else + { + // Multiple separate databases - use individual transactions per context + SavePolicyWithIndividualTransactions(store, contexts, policiesByContext); + } + } + + private void SavePolicyWithSharedTransaction(IPolicyStore store, List contexts, + List> policiesByContext) + { + var sharedConnection = _contextProvider?.GetSharedConnection(); + + if (sharedConnection != null) + { + // Use connection-level transaction (required for PostgreSQL savepoint handling) + if (sharedConnection.State != System.Data.ConnectionState.Open) + { + sharedConnection.Open(); + } + + using var transaction = sharedConnection.BeginTransaction(); + try + { + // Enlist all contexts in the connection-level transaction + foreach (var context in contexts) + { + context.Database.UseTransaction(transaction); + } + + // Clear existing policies from all contexts + foreach (var context in contexts) + { + var dbSet = GetCasbinRuleDbSet(context, null); +#if NET7_0_OR_GREATER + // EF Core 7+: Use ExecuteDelete for better performance (set-based delete without loading entities) + dbSet.ExecuteDelete(); +#else + // EF Core 3.1-6.0: Fall back to traditional approach + var existingRules = dbSet.ToList(); + dbSet.RemoveRange(existingRules); + context.SaveChanges(); +#endif + } + + // Add new policies to respective contexts + foreach (var group in policiesByContext) + { + var context = group.Key; + var dbSet = GetCasbinRuleDbSet(context, null); + var saveRules = OnSavePolicy(store, group); + dbSet.AddRange(saveRules); + context.SaveChanges(); + } + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + else + { + // Fall back to context-level transaction (for backward compatibility or when no shared connection) + var primaryContext = contexts.First(); + using var transaction = primaryContext.Database.BeginTransaction(); + + try + { + // Clear existing policies from all contexts + foreach (var context in contexts) + { + if (context != primaryContext) + { + var dbTransaction = transaction.GetDbTransaction(); + context.Database.UseTransaction(dbTransaction); + } + + var dbSet = GetCasbinRuleDbSet(context, null); +#if NET7_0_OR_GREATER + // EF Core 7+: Use ExecuteDelete for better performance (set-based delete without loading entities) + dbSet.ExecuteDelete(); +#else + // EF Core 3.1-6.0: Fall back to traditional approach + var existingRules = dbSet.ToList(); + dbSet.RemoveRange(existingRules); + context.SaveChanges(); +#endif + } + + // Add new policies to respective contexts + foreach (var group in policiesByContext) + { + var context = group.Key; + var dbSet = GetCasbinRuleDbSet(context, null); + var saveRules = OnSavePolicy(store, group); + dbSet.AddRange(saveRules); + context.SaveChanges(); + } + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + + private void SavePolicyWithIndividualTransactions(IPolicyStore store, List contexts, + List> policiesByContext) + { + // Use separate transactions for each context (required for separate SQLite databases) + // Note: This is not atomic across contexts but is necessary for SQLite limitations + foreach (var context in contexts) + { + using var transaction = context.Database.BeginTransaction(); + try + { + // Clear existing policies from this context + var dbSet = GetCasbinRuleDbSet(context, null); +#if NET7_0_OR_GREATER + // EF Core 7+: Use ExecuteDelete for better performance (set-based delete without loading entities) + dbSet.ExecuteDelete(); +#else + // EF Core 3.1-6.0: Fall back to traditional approach + var existingRules = dbSet.ToList(); + dbSet.RemoveRange(existingRules); + context.SaveChanges(); +#endif + + // Add new policies to this context + var policiesForContext = policiesByContext.FirstOrDefault(g => g.Key == context); + if (policiesForContext != null) + { + var saveRules = OnSavePolicy(store, policiesForContext); + dbSet.AddRange(saveRules); + context.SaveChanges(); + } + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + + /// + /// Determines if contexts can share a transaction by checking if they use the same physical DbConnection instance. + /// + /// + /// EF Core's UseTransaction() requires that all contexts use the SAME DbConnection object instance + /// (reference equality), not just identical connection strings. Users must explicitly create contexts + /// with a shared DbConnection object for transaction coordination to work. + /// + /// List of contexts to check for shared connection + /// True if all contexts share the same DbConnection instance; otherwise false + private bool CanShareTransaction(List contexts) + { + // Check if all contexts share the same physical DbConnection object + // EF Core's UseTransaction() requires reference equality, not string equality + if (contexts.Count <= 1) return true; - var saveRules = OnSavePolicy(store, persistPolicies); - PersistPolicies.AddRange(saveRules); - dbContext.SaveChanges(); + try + { + var firstConnection = contexts[0].Database.GetDbConnection(); + + if (firstConnection == null) + { + return false; + } + + // Check reference equality - contexts must share the SAME connection object + return contexts.All(c => + { + var connection = c.Database.GetDbConnection(); + return ReferenceEquals(connection, firstConnection); + }); + } + catch (Exception) + { + // If we can't determine connection compatibility for any reason, + // assume separate connections for safety + return false; + } } public virtual async Task SavePolicyAsync(IPolicyStore store) { - var dbContext = GetOrResolveDbContext(); var persistPolicies = new List(); persistPolicies.ReadPolicyFromCasbinModel(store); @@ -130,13 +391,261 @@ public virtual async Task SavePolicyAsync(IPolicyStore store) return; } - var existRule = PersistPolicies.ToList(); - PersistPolicies.RemoveRange(existRule); - await dbContext.SaveChangesAsync(); + // Group policies by their target context + var policiesByContext = persistPolicies + .GroupBy(p => _contextProvider.GetContextForPolicyType(p.Type)) + .ToList(); + + var contexts = _contextProvider.GetAllContexts().Distinct().ToList(); + + Console.WriteLine($"[DIAGNOSTIC] SavePolicyAsync: {contexts.Count} contexts, {persistPolicies.Count} policies"); + + // Check if we can use a shared transaction (all contexts use same connection) + bool canShareTransaction = CanShareTransaction(contexts); + Console.WriteLine($"[DIAGNOSTIC] CanShareTransaction returned: {canShareTransaction}"); + + if (contexts.Count == 1 || canShareTransaction) + { + Console.WriteLine($"[DIAGNOSTIC] Using SHARED transaction path"); + // Single context or shared connection - use single transaction + await SavePolicyWithSharedTransactionAsync(store, contexts, policiesByContext); + } + else + { + Console.WriteLine($"[DIAGNOSTIC] Using INDIVIDUAL transactions path"); + // Multiple separate databases - use individual transactions per context + await SavePolicyWithIndividualTransactionsAsync(store, contexts, policiesByContext); + } + } + + private async Task SavePolicyWithSharedTransactionAsync(IPolicyStore store, List contexts, + List> policiesByContext) + { + var sharedConnection = _contextProvider?.GetSharedConnection(); - var saveRules = OnSavePolicy(store, persistPolicies); - await PersistPolicies.AddRangeAsync(saveRules); - await dbContext.SaveChangesAsync(); + if (sharedConnection != null) + { + // Use connection-level transaction (required for PostgreSQL savepoint handling) + Console.WriteLine($"[DIAGNOSTIC] SavePolicyWithSharedTransactionAsync: Using connection-level transaction for {contexts.Count} contexts"); + + if (sharedConnection.State != System.Data.ConnectionState.Open) + { + await sharedConnection.OpenAsync(); + } + + await using var transaction = await sharedConnection.BeginTransactionAsync(); + Console.WriteLine($"[DIAGNOSTIC] Connection-level transaction started: {transaction.GetType().Name}"); + + try + { + // Enlist all contexts in the connection-level transaction + Console.WriteLine($"[DIAGNOSTIC] Enlisting all {contexts.Count} contexts in connection-level transaction"); + foreach (var context in contexts) + { + context.Database.UseTransaction(transaction); + Console.WriteLine($"[DIAGNOSTIC] Enlisted: {context.GetType().Name}"); + } + + // Clear existing policies from all contexts + Console.WriteLine($"[DIAGNOSTIC] Phase 1: Deleting existing policies from {contexts.Count} contexts"); + for (int i = 0; i < contexts.Count; i++) + { + var context = contexts[i]; + Console.WriteLine($"[DIAGNOSTIC] Processing context {i + 1}/{contexts.Count}: {context.GetType().Name}"); + + var dbSet = GetCasbinRuleDbSet(context, null); + Console.WriteLine($"[DIAGNOSTIC] Executing delete on DbSet..."); +#if NET7_0_OR_GREATER + // EF Core 7+: Use ExecuteDeleteAsync for better performance (set-based delete without loading entities) + await dbSet.ExecuteDeleteAsync(); + Console.WriteLine($"[DIAGNOSTIC] ExecuteDeleteAsync completed"); +#else + // EF Core 3.1-6.0: Fall back to traditional approach + var existingRules = await dbSet.ToListAsync(); + dbSet.RemoveRange(existingRules); + await context.SaveChangesAsync(); + Console.WriteLine($"[DIAGNOSTIC] SaveChangesAsync completed after RemoveRange"); +#endif + } + + Console.WriteLine($"[DIAGNOSTIC] Phase 2: Adding new policies to {policiesByContext.Count} contexts"); + // Add new policies to respective contexts + for (int i = 0; i < policiesByContext.Count; i++) + { + var group = policiesByContext[i]; + var context = group.Key; + Console.WriteLine($"[DIAGNOSTIC] Adding policies {i + 1}/{policiesByContext.Count} to context: {context.GetType().Name}"); + + var dbSet = GetCasbinRuleDbSet(context, null); + var saveRules = OnSavePolicy(store, group); + Console.WriteLine($"[DIAGNOSTIC] Adding {saveRules.Count()} policies to DbSet"); + await dbSet.AddRangeAsync(saveRules); + + Console.WriteLine($"[DIAGNOSTIC] Calling SaveChangesAsync..."); + await context.SaveChangesAsync(); + Console.WriteLine($"[DIAGNOSTIC] SaveChangesAsync completed"); + } + + Console.WriteLine($"[DIAGNOSTIC] Committing connection-level transaction..."); + await transaction.CommitAsync(); + Console.WriteLine($"[DIAGNOSTIC] Transaction committed successfully"); + + // Clear transaction state from all contexts to prevent SAVEPOINT errors + // in subsequent SaveChanges() calls + foreach (var context in contexts) + { + context.Database.UseTransaction(null); + } + Console.WriteLine($"[DIAGNOSTIC] Cleared transaction state from all contexts"); + } + catch (Exception ex) + { + Console.WriteLine($"[DIAGNOSTIC] EXCEPTION CAUGHT: {ex.GetType().Name}: {ex.Message}"); + Console.WriteLine($"[DIAGNOSTIC] Rolling back connection-level transaction..."); + await transaction.RollbackAsync(); + Console.WriteLine($"[DIAGNOSTIC] Transaction rolled back"); + + // Clear transaction state from all contexts + foreach (var context in contexts) + { + context.Database.UseTransaction(null); + } + Console.WriteLine($"[DIAGNOSTIC] Cleared transaction state from all contexts after rollback"); + throw; + } + } + else + { + // Fall back to context-level transaction + var primaryContext = contexts.First(); + Console.WriteLine($"[DIAGNOSTIC] SavePolicyWithSharedTransactionAsync: Using context-level transaction for {contexts.Count} contexts"); + Console.WriteLine($"[DIAGNOSTIC] Primary context: {primaryContext.GetType().Name}"); + + await using var transaction = await primaryContext.Database.BeginTransactionAsync(); + Console.WriteLine($"[DIAGNOSTIC] Transaction started: {transaction.TransactionId}"); + + try + { + // Clear existing policies from all contexts + Console.WriteLine($"[DIAGNOSTIC] Phase 1: Deleting existing policies from {contexts.Count} contexts"); + for (int i = 0; i < contexts.Count; i++) + { + var context = contexts[i]; + Console.WriteLine($"[DIAGNOSTIC] Processing context {i + 1}/{contexts.Count}: {context.GetType().Name}"); + + if (context != primaryContext) + { + var dbTransaction = transaction.GetDbTransaction(); + Console.WriteLine($"[DIAGNOSTIC] Enlisting context in transaction via UseTransaction()"); + // Use synchronous UseTransaction since we're just enlisting in an existing transaction + context.Database.UseTransaction(dbTransaction); + } + else + { + Console.WriteLine($"[DIAGNOSTIC] This is the primary context (already in transaction)"); + } + + var dbSet = GetCasbinRuleDbSet(context, null); + Console.WriteLine($"[DIAGNOSTIC] Executing delete on DbSet..."); +#if NET7_0_OR_GREATER + // EF Core 7+: Use ExecuteDeleteAsync for better performance (set-based delete without loading entities) + await dbSet.ExecuteDeleteAsync(); + Console.WriteLine($"[DIAGNOSTIC] ExecuteDeleteAsync completed"); +#else + // EF Core 3.1-6.0: Fall back to traditional approach + var existingRules = await dbSet.ToListAsync(); + dbSet.RemoveRange(existingRules); + await context.SaveChangesAsync(); + Console.WriteLine($"[DIAGNOSTIC] SaveChangesAsync completed after RemoveRange"); +#endif + } + + Console.WriteLine($"[DIAGNOSTIC] Phase 2: Adding new policies to {policiesByContext.Count} contexts"); + // Add new policies to respective contexts + for (int i = 0; i < policiesByContext.Count; i++) + { + var group = policiesByContext[i]; + var context = group.Key; + Console.WriteLine($"[DIAGNOSTIC] Adding policies {i + 1}/{policiesByContext.Count} to context: {context.GetType().Name}"); + + var dbSet = GetCasbinRuleDbSet(context, null); + var saveRules = OnSavePolicy(store, group); + Console.WriteLine($"[DIAGNOSTIC] Adding {saveRules.Count()} policies to DbSet"); + await dbSet.AddRangeAsync(saveRules); + + Console.WriteLine($"[DIAGNOSTIC] Calling SaveChangesAsync..."); + await context.SaveChangesAsync(); + Console.WriteLine($"[DIAGNOSTIC] SaveChangesAsync completed"); + } + + Console.WriteLine($"[DIAGNOSTIC] Committing transaction..."); + await transaction.CommitAsync(); + Console.WriteLine($"[DIAGNOSTIC] Transaction committed successfully"); + + // Clear transaction state from all contexts to prevent SAVEPOINT errors + foreach (var context in contexts) + { + context.Database.UseTransaction(null); + } + Console.WriteLine($"[DIAGNOSTIC] Cleared transaction state from all contexts"); + } + catch (Exception ex) + { + Console.WriteLine($"[DIAGNOSTIC] EXCEPTION CAUGHT: {ex.GetType().Name}: {ex.Message}"); + Console.WriteLine($"[DIAGNOSTIC] Rolling back transaction..."); + await transaction.RollbackAsync(); + Console.WriteLine($"[DIAGNOSTIC] Transaction rolled back"); + + // Clear transaction state from all contexts + foreach (var context in contexts) + { + context.Database.UseTransaction(null); + } + Console.WriteLine($"[DIAGNOSTIC] Cleared transaction state from all contexts after rollback"); + throw; + } + } + } + + private async Task SavePolicyWithIndividualTransactionsAsync(IPolicyStore store, List contexts, + List> policiesByContext) + { + // Use separate transactions for each context (required for separate SQLite databases) + // Note: This is not atomic across contexts but is necessary for SQLite limitations + foreach (var context in contexts) + { + await using var transaction = await context.Database.BeginTransactionAsync(); + try + { + // Clear existing policies from this context + var dbSet = GetCasbinRuleDbSet(context, null); +#if NET7_0_OR_GREATER + // EF Core 7+: Use ExecuteDeleteAsync for better performance (set-based delete without loading entities) + await dbSet.ExecuteDeleteAsync(); +#else + // EF Core 3.1-6.0: Fall back to traditional approach + var existingRules = await dbSet.ToListAsync(); + dbSet.RemoveRange(existingRules); + await context.SaveChangesAsync(); +#endif + + // Add new policies to this context + var policiesForContext = policiesByContext.FirstOrDefault(g => g.Key == context); + if (policiesForContext != null) + { + var saveRules = OnSavePolicy(store, policiesForContext); + await dbSet.AddRangeAsync(saveRules); + await context.SaveChangesAsync(); + } + + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } } #endregion @@ -145,64 +654,95 @@ public virtual async Task SavePolicyAsync(IPolicyStore store) public virtual void AddPolicy(string section, string policyType, IPolicyValues values) { - var dbContext = GetOrResolveDbContext(); + Console.WriteLine($"[ADAPTER] AddPolicy INVOKED: policyType={policyType}, section={section}"); + Console.WriteLine($"[ADAPTER] Call stack: {new System.Diagnostics.StackTrace(1, true)}"); + if (values.Count is 0) { + Console.WriteLine($"[ADAPTER] AddPolicy: No values provided, returning"); return; } + var context = GetContextForPolicyType(policyType); + Console.WriteLine($"[ADAPTER] Context: {context.GetType().Name}"); + var dbSet = GetCasbinRuleDbSetForPolicyType(context, policyType); var filter = new PolicyFilter(policyType, 0, values); - var persistPolicies = filter.Apply(PersistPolicies); + var persistPolicies = filter.Apply(dbSet); if (persistPolicies.Any()) { + Console.WriteLine($"[ADAPTER] AddPolicy: Policy already exists, returning"); return; } + // No explicit transaction needed for individual AutoSave operations + // EF Core will create implicit transaction for SaveChanges() + // This prevents SAVEPOINT errors when multiple operations are called sequentially InternalAddPolicy(section, policyType, values); - dbContext.SaveChanges(); + Console.WriteLine($"[ADAPTER] Calling context.SaveChanges() to commit immediately"); + context.SaveChanges(); + Console.WriteLine($"[ADAPTER] SaveChanges() completed"); } public virtual async Task AddPolicyAsync(string section, string policyType, IPolicyValues values) { - var dbContext = GetOrResolveDbContext(); + Console.WriteLine($"[ADAPTER] AddPolicyAsync INVOKED: policyType={policyType}, section={section}"); + Console.WriteLine($"[ADAPTER] Call stack: {new System.Diagnostics.StackTrace(1, true)}"); + if (values.Count is 0) { + Console.WriteLine($"[ADAPTER] AddPolicyAsync: No values provided, returning"); return; } + var context = GetContextForPolicyType(policyType); + Console.WriteLine($"[ADAPTER] Context: {context.GetType().Name}"); + + var dbSet = GetCasbinRuleDbSetForPolicyType(context, policyType); var filter = new PolicyFilter(policyType, 0, values); - var persistPolicies = filter.Apply(PersistPolicies); + var persistPolicies = filter.Apply(dbSet); if (persistPolicies.Any()) { + Console.WriteLine($"[ADAPTER] AddPolicyAsync: Policy already exists, returning"); return; } + // No explicit transaction needed for individual AutoSave operations + // EF Core will create implicit transaction for SaveChangesAsync() + // This prevents SAVEPOINT errors when multiple operations are called sequentially await InternalAddPolicyAsync(section, policyType, values); - await dbContext.SaveChangesAsync(); + Console.WriteLine($"[ADAPTER] Calling context.SaveChangesAsync() to commit immediately"); + await context.SaveChangesAsync(); + Console.WriteLine($"[ADAPTER] SaveChangesAsync completed"); } public virtual void AddPolicies(string section, string policyType, IReadOnlyList valuesList) { - var dbContext = GetOrResolveDbContext(); if (valuesList.Count is 0) { return; } + + var context = GetContextForPolicyType(policyType); + + // No explicit transaction needed for individual AutoSave operations InternalAddPolicies(section, policyType, valuesList); - dbContext.SaveChanges(); + context.SaveChanges(); } public virtual async Task AddPoliciesAsync(string section, string policyType, IReadOnlyList valuesList) { - var dbContext = GetOrResolveDbContext(); if (valuesList.Count is 0) { return; } + + var context = GetContextForPolicyType(policyType); + + // No explicit transaction needed for individual AutoSave operations await InternalAddPoliciesAsync(section, policyType, valuesList); - await dbContext.SaveChangesAsync(); + await context.SaveChangesAsync(); } #endregion @@ -211,125 +751,147 @@ public virtual async Task AddPoliciesAsync(string section, string policyType, IR public virtual void RemovePolicy(string section, string policyType, IPolicyValues values) { - var dbContext = GetOrResolveDbContext(); if (values.Count is 0) { return; } + + var context = GetContextForPolicyType(policyType); + + // No explicit transaction needed for individual AutoSave operations InternalRemovePolicy(section, policyType, values); - dbContext.SaveChanges(); + context.SaveChanges(); } public virtual async Task RemovePolicyAsync(string section, string policyType, IPolicyValues values) { - var dbContext = GetOrResolveDbContext(); if (values.Count is 0) { return; } + + var context = GetContextForPolicyType(policyType); + + // No explicit transaction needed for individual AutoSave operations InternalRemovePolicy(section, policyType, values); - await dbContext.SaveChangesAsync(); + await context.SaveChangesAsync(); } public virtual void RemoveFilteredPolicy(string section, string policyType, int fieldIndex, IPolicyValues fieldValues) { - var dbContext = GetOrResolveDbContext(); if (fieldValues.Count is 0) { return; } + + var context = GetContextForPolicyType(policyType); + + // No explicit transaction needed for individual AutoSave operations InternalRemoveFilteredPolicy(section, policyType, fieldIndex, fieldValues); - dbContext.SaveChanges(); + context.SaveChanges(); } public virtual async Task RemoveFilteredPolicyAsync(string section, string policyType, int fieldIndex, IPolicyValues fieldValues) { - var dbContext = GetOrResolveDbContext(); if (fieldValues.Count is 0) { return; } + + var context = GetContextForPolicyType(policyType); + + // No explicit transaction needed for individual AutoSave operations InternalRemoveFilteredPolicy(section, policyType, fieldIndex, fieldValues); - await dbContext.SaveChangesAsync(); + await context.SaveChangesAsync(); } public virtual void RemovePolicies(string section, string policyType, IReadOnlyList valuesList) { - var dbContext = GetOrResolveDbContext(); if (valuesList.Count is 0) { return; } + + var context = GetContextForPolicyType(policyType); + + // No explicit transaction needed for individual AutoSave operations InternalRemovePolicies(section, policyType, valuesList); - dbContext.SaveChanges(); + context.SaveChanges(); } public virtual async Task RemovePoliciesAsync(string section, string policyType, IReadOnlyList valuesList) { - var dbContext = GetOrResolveDbContext(); if (valuesList.Count is 0) { return; } + + var context = GetContextForPolicyType(policyType); + + // No explicit transaction needed for individual AutoSave operations InternalRemovePolicies(section, policyType, valuesList); - await dbContext.SaveChangesAsync(); + await context.SaveChangesAsync(); } #endregion #region Update policy - + public void UpdatePolicy(string section, string policyType, IPolicyValues oldValues, IPolicyValues newValues) { - var dbContext = GetOrResolveDbContext(); if (newValues.Count is 0) { return; } - using var transaction = dbContext.Database.BeginTransaction(); + + var context = GetContextForPolicyType(policyType); + + // No explicit transaction needed for individual AutoSave operations InternalUpdatePolicy(section, policyType, oldValues, newValues); - dbContext.SaveChanges(); - transaction.Commit(); + context.SaveChanges(); } public async Task UpdatePolicyAsync(string section, string policyType, IPolicyValues oldValues, IPolicyValues newValues) { - var dbContext = GetOrResolveDbContext(); if (newValues.Count is 0) { return; } - await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + var context = GetContextForPolicyType(policyType); + + // No explicit transaction needed for individual AutoSave operations await InternalUpdatePolicyAsync(section, policyType, oldValues, newValues); - await dbContext.SaveChangesAsync(); - await transaction.CommitAsync(); + await context.SaveChangesAsync(); } public void UpdatePolicies(string section, string policyType, IReadOnlyList oldValuesList, IReadOnlyList newValuesList) { - var dbContext = GetOrResolveDbContext(); if (newValuesList.Count is 0) { return; } - using var transaction = dbContext.Database.BeginTransaction(); + + var context = GetContextForPolicyType(policyType); + + // No explicit transaction needed for individual AutoSave operations InternalUpdatePolicies(section, policyType, oldValuesList, newValuesList); - dbContext.SaveChanges(); - transaction.Commit(); + context.SaveChanges(); } public async Task UpdatePoliciesAsync(string section, string policyType, IReadOnlyList oldValuesList, IReadOnlyList newValuesList) { - var dbContext = GetOrResolveDbContext(); if (newValuesList.Count is 0) { return; } - await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + var context = GetContextForPolicyType(policyType); + + // No explicit transaction needed for individual AutoSave operations await InternalUpdatePoliciesAsync(section, policyType, oldValuesList, newValuesList); - await dbContext.SaveChangesAsync(); - await transaction.CommitAsync(); + await context.SaveChangesAsync(); } #endregion @@ -337,22 +899,40 @@ public async Task UpdatePoliciesAsync(string section, string policyType, IReadOn #region IFilteredAdapter public bool IsFiltered { get; private set; } - + public void LoadFilteredPolicy(IPolicyStore store, IPolicyFilter filter) { - var persistPolicies = PersistPolicies.AsNoTracking(); - persistPolicies = filter.Apply(persistPolicies); - persistPolicies = OnLoadPolicy(store, persistPolicies); - store.LoadPolicyFromPersistPolicy(persistPolicies.ToList()); + var allPolicies = new List(); + + // Load from each unique context + foreach (var context in _contextProvider.GetAllContexts().Distinct()) + { + var dbSet = GetCasbinRuleDbSet(context, null); + var policies = dbSet.AsNoTracking(); + var filtered = filter.Apply(policies); + allPolicies.AddRange(filtered.ToList()); + } + + var finalPolicies = OnLoadPolicy(store, allPolicies.AsQueryable()); + store.LoadPolicyFromPersistPolicy(finalPolicies.ToList()); IsFiltered = true; } public async Task LoadFilteredPolicyAsync(IPolicyStore store, IPolicyFilter filter) { - var persistPolicies = PersistPolicies.AsNoTracking(); - persistPolicies = filter.Apply(persistPolicies); - persistPolicies = OnLoadPolicy(store, persistPolicies); - store.LoadPolicyFromPersistPolicy(await persistPolicies.ToListAsync()); + var allPolicies = new List(); + + // Load from each unique context + foreach (var context in _contextProvider.GetAllContexts().Distinct()) + { + var dbSet = GetCasbinRuleDbSet(context, null); + var policies = dbSet.AsNoTracking(); + var filtered = filter.Apply(policies); + allPolicies.AddRange(await filtered.ToListAsync()); + } + + var finalPolicies = OnLoadPolicy(store, allPolicies.AsQueryable()); + store.LoadPolicyFromPersistPolicy(finalPolicies.ToList()); IsFiltered = true; } diff --git a/Casbin.Persist.Adapter.EFCore/ICasbinDbContextProvider.cs b/Casbin.Persist.Adapter.EFCore/ICasbinDbContextProvider.cs new file mode 100644 index 0000000..d35bc9a --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore/ICasbinDbContextProvider.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using Microsoft.EntityFrameworkCore; + +namespace Casbin.Persist.Adapter.EFCore +{ + /// + /// Provides DbContext instances for different policy types, enabling multi-context scenarios + /// where different policy types can be stored in separate schemas, tables, or databases. + /// + /// The type of the primary key + public interface ICasbinDbContextProvider where TKey : IEquatable + { + /// + /// Gets the DbContext that should handle the specified policy type. + /// + /// The policy type identifier (e.g., "p", "p2", "g", "g2") + /// The DbContext instance responsible for this policy type + DbContext GetContextForPolicyType(string policyType); + + /// + /// Gets all unique DbContext instances managed by this provider. + /// Used for operations that need to coordinate across all contexts (e.g., SavePolicy, LoadPolicy). + /// + /// An enumerable of all distinct DbContext instances + IEnumerable GetAllContexts(); + + /// + /// Gets the shared DbConnection if all contexts use the same physical connection. + /// Returns null if contexts use separate connections. + /// + /// + /// When non-null, the adapter starts transactions at the connection level + /// (connection.BeginTransaction()) rather than context level, which is required + /// for proper savepoint handling in PostgreSQL and other databases that require + /// explicit transaction blocks before creating savepoints. + /// + /// Return null for scenarios where contexts use separate physical connections + /// (e.g., separate SQLite database files), in which case the adapter will use + /// separate transactions for each context. + /// + /// The shared DbConnection, or null if contexts use separate connections + DbConnection? GetSharedConnection(); + } +} diff --git a/Casbin.Persist.Adapter.EFCore/ServiceProviderContextProvider.cs b/Casbin.Persist.Adapter.EFCore/ServiceProviderContextProvider.cs new file mode 100644 index 0000000..3d9b2c8 --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore/ServiceProviderContextProvider.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using Microsoft.EntityFrameworkCore; + +namespace Casbin.Persist.Adapter.EFCore +{ + /// + /// Context provider that resolves a single DbContext from IServiceProvider (for DI scenarios). + /// + /// The type of the primary key + /// The type of DbContext to resolve + internal class ServiceProviderContextProvider : ICasbinDbContextProvider + where TKey : IEquatable + where TDbContext : DbContext + { + private readonly IServiceProvider _serviceProvider; + private TDbContext _cachedContext; + + public ServiceProviderContextProvider(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + } + + public DbContext GetContextForPolicyType(string policyType) + { + return GetOrResolveContext(); + } + + public IEnumerable GetAllContexts() + { + yield return GetOrResolveContext(); + } + + public DbConnection GetSharedConnection() + { + // Single context - return its connection for shared transaction support + return GetOrResolveContext().Database.GetDbConnection(); + } + + private TDbContext GetOrResolveContext() + { + if (_cachedContext != null) + { + return _cachedContext; + } + + _cachedContext = _serviceProvider.GetService(typeof(TDbContext)) as TDbContext + ?? throw new InvalidOperationException($"Unable to resolve service for type '{typeof(TDbContext)}' from IServiceProvider."); + + return _cachedContext; + } + } +} diff --git a/Casbin.Persist.Adapter.EFCore/SingleContextProvider.cs b/Casbin.Persist.Adapter.EFCore/SingleContextProvider.cs new file mode 100644 index 0000000..b583bdb --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore/SingleContextProvider.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; + +namespace Casbin.Persist.Adapter.EFCore +{ + /// + /// Default context provider that uses a single DbContext for all policy types. + /// This maintains backward compatibility with the original single-context behavior. + /// + /// The type of the primary key + public class SingleContextProvider : ICasbinDbContextProvider + where TKey : IEquatable + { + private readonly DbContext _context; + + /// + /// Creates a new instance of SingleContextProvider with the specified context. + /// + /// The DbContext to use for all policy types + /// Thrown when context is null + public SingleContextProvider(DbContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + /// Returns the single context for any policy type. + /// + /// The policy type (ignored in this implementation) + /// The single DbContext instance + public DbContext GetContextForPolicyType(string policyType) + { + return _context; + } + + /// + /// Returns a collection containing only the single context. + /// + /// An enumerable containing the single DbContext + public IEnumerable GetAllContexts() + { + return new[] { _context }; + } + + /// + /// Returns null since single-context scenarios don't have a shared connection + /// (only one context, so the concept of "shared" doesn't apply). + /// + /// Always returns null + public System.Data.Common.DbConnection? GetSharedConnection() + { + return null; + } + } +} diff --git a/MULTI_CONTEXT_DESIGN.md b/MULTI_CONTEXT_DESIGN.md new file mode 100644 index 0000000..8470c25 --- /dev/null +++ b/MULTI_CONTEXT_DESIGN.md @@ -0,0 +1,636 @@ +# Multi-Context Support Design Document + +## Overview + +This document provides technical architecture and implementation details for multi-context support in the EFCore adapter. For user-facing setup instructions, see [MULTI_CONTEXT_USAGE_GUIDE.md](MULTI_CONTEXT_USAGE_GUIDE.md). + +**Purpose:** Enable multiple `CasbinDbContext` instances to store different policy types in separate database locations (schemas, tables, or databases) while maintaining transactional integrity where possible. + +## Background + +### Current Architecture +- Single `DbContext` per adapter instance +- Single `DbSet` for all policy types +- All policy types stored in the same table + +### Motivation +- Store different policy types in separate schemas/tables +- Enable multi-tenant scenarios with separate contexts +- Separate concerns for organizational requirements + +### Requirements + +**Functional:** +1. Route policy types to different `DbContext` instances +2. Maintain ACID guarantees when contexts share connections +3. Preserve backward compatibility + +**Technical:** +1. Use EF Core's `UseTransaction()` for shared transactions +2. Detect connection compatibility at runtime +3. Gracefully degrade to individual transactions when sharing is not possible + +**Non-Requirements:** +- Distributed transactions across different databases/servers +- Automatic connection string management +- Schema migration coordination + +## Architecture + +### Context Provider Pattern + +#### ICasbinDbContextProvider Interface + +```csharp +public interface ICasbinDbContextProvider where TKey : IEquatable +{ + /// + /// Gets the DbContext for a specific policy type (e.g., "p", "p2", "g", "g2") + /// + DbContext GetContextForPolicyType(string policyType); + + /// + /// Gets all unique DbContext instances used by this provider. + /// Used for operations that coordinate across all contexts (SavePolicy, LoadPolicy) + /// + IEnumerable GetAllContexts(); + + /// + /// Gets the shared DbConnection if all contexts use the same physical connection. + /// Returns null if contexts use separate connections. + /// + /// + /// When non-null, the adapter starts transactions at the connection level + /// (connection.BeginTransaction()) rather than context level, which is required + /// for proper savepoint handling in PostgreSQL and other databases that require + /// explicit transaction blocks before creating savepoints. + /// + /// Return null for scenarios where contexts use separate physical connections + /// (e.g., separate SQLite database files), in which case the adapter will use + /// separate transactions for each context. + /// + /// The shared DbConnection, or null if contexts use separate connections + DbConnection? GetSharedConnection(); +} +``` + +**Contract:** +- `GetContextForPolicyType()` must return a valid DbContext for any policy type +- `GetAllContexts()` must return all distinct contexts (used for SavePolicy, LoadPolicy) +- Same policy type should always route to the same context instance +- `GetSharedConnection()` must return the shared DbConnection when all contexts use the same physical connection, or null when contexts use separate connections + +#### Default Implementation + +```csharp +/// +/// Default provider using single context for all policy types (backward compatibility) +/// +public class SingleContextProvider : ICasbinDbContextProvider + where TKey : IEquatable +{ + private readonly DbContext _context; + + public SingleContextProvider(DbContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public DbContext GetContextForPolicyType(string policyType) => _context; + public IEnumerable GetAllContexts() => new[] { _context }; + + /// + /// Returns null since single-context scenarios don't have a shared connection + /// (only one context, so the concept of "shared" doesn't apply). + /// + public DbConnection? GetSharedConnection() => null; +} +``` + +### Constructor Design + +```csharp +public partial class EFCoreAdapter +{ + private readonly ICasbinDbContextProvider _contextProvider; + private readonly Dictionary<(DbContext, string), DbSet> _persistPoliciesByContext; + + /// + /// NEW: Multi-context constructor with custom provider + /// + public EFCoreAdapter(ICasbinDbContextProvider contextProvider) + { + _contextProvider = contextProvider ?? throw new ArgumentNullException(nameof(contextProvider)); + _persistPoliciesByContext = new Dictionary<(DbContext, string), DbSet>(); + DbContext = null; // Kept for backward compatibility + } + + /// + /// EXISTING: Single-context constructor (unchanged behavior) + /// + public EFCoreAdapter(TDbContext context) + { + DbContext = context ?? throw new ArgumentNullException(nameof(context)); + _contextProvider = new SingleContextProvider(context); + _persistPoliciesByContext = new Dictionary<(DbContext, string), DbSet>(); + } + + protected TDbContext DbContext { get; } // Legacy property for compatibility +} +``` + +**Backward Compatibility:** +- Existing single-context constructor wraps context in `SingleContextProvider` +- All existing code continues to work unchanged +- `DbContext` property maintained for external code that may access it + +### Transaction Coordination + +#### Provider-Declared Connection Strategy + +The adapter uses the provider's `GetSharedConnection()` method to determine transaction strategy: + +```csharp +var sharedConnection = _contextProvider?.GetSharedConnection(); + +if (sharedConnection != null) +{ + // Use connection-level transaction (atomic) + SavePolicyWithSharedTransaction_ConnectionLevel(sharedConnection, contexts, policiesByContext); +} +else +{ + // Use context-level transactions (not atomic across contexts) + SavePolicyWithIndividualTransactions(contexts, policiesByContext); +} +``` + +**Strategy:** +- Provider explicitly declares connection topology via `GetSharedConnection()` +- If provider returns a DbConnection → all contexts share that connection → use connection-level transaction +- If provider returns null → contexts use separate connections → use individual transactions +- No runtime detection → provider knows best about connection strategy + +#### Connection-Level Transaction Pattern (PostgreSQL Savepoint Support) + +When provider returns a shared DbConnection: + +```csharp +// Actual implementation (simplified) +var sharedConnection = _contextProvider?.GetSharedConnection(); + +if (sharedConnection.State != ConnectionState.Open) +{ + sharedConnection.Open(); +} + +using var transaction = sharedConnection.BeginTransaction(); // ← Connection-level + +try +{ + // Enlist all contexts in the connection-level transaction + foreach (var context in contexts) + { + context.Database.UseTransaction(transaction); + } + + // Clear and add policies for each context + foreach (var contextGroup in policiesByContext) + { + var dbSet = GetCasbinRuleDbSetForPolicyType(contextGroup.Key, null); + + // Clear existing policies + var existingPolicies = dbSet.ToList(); + dbSet.RemoveRange(existingPolicies); + contextGroup.Key.SaveChanges(); + + // Add new policies + dbSet.AddRange(contextGroup); + contextGroup.Key.SaveChanges(); + } + + transaction.Commit(); // Atomic across all contexts +} +catch +{ + transaction.Rollback(); + throw; +} +``` + +**Key Points:** +- Transaction started at **connection level** (`connection.BeginTransaction()`) not context level +- Required for PostgreSQL savepoint handling - PostgreSQL requires explicit `BEGIN` before creating savepoints +- When EF Core uses `UseTransaction()` with multiple contexts on same connection, it creates savepoints internally +- PostgreSQL savepoints require an active transaction block at the connection level +- All contexts enlisted in the same connection-level transaction using `context.Database.UseTransaction()` + +#### Individual Transaction Pattern (Fallback) + +When provider returns null (separate connections): + +```csharp +// Pseudocode - WARNING: Not atomic across contexts +foreach (var context in contexts) +{ + using var transaction = context.Database.BeginTransaction(); + try + { + // Perform operations on context + context.SaveChanges(); + transaction.Commit(); // Commits this context only + } + catch + { + transaction.Rollback(); + throw; // Failure in one context doesn't rollback others + } +} +``` + +### Database Support + +| Database | Same Schema | Different Schemas | Different Tables | Separate Files | Atomic Tx | +|----------|-------------|-------------------|------------------|----------------|-----------| +| **SQL Server** | ✅ | ✅ | ✅ | ✅ (same server) | ✅ | +| **PostgreSQL** | ✅ | ✅ | ✅ | ❌ | ✅ (same DB) | +| **MySQL** | ✅ | ✅ | ✅ | ❌ | ✅ (same DB) | +| **SQLite** | ✅ | N/A | ✅ (same file) | ⚠️ (no atomicity) | ✅ (same file only) | + +**Key Constraints:** +- All contexts must use the **same DbConnection object instance** for shared transactions +- Users must explicitly create and pass a shared connection object to all contexts +- Distributed transactions (cross-database) are not supported + +## Implementation Details + +### Internal Method Changes + +#### Modified Virtual Method + +```csharp +// Old signature - kept for backward compatibility, marked obsolete +[Obsolete("Use GetCasbinRuleDbSet(DbContext, string) instead. This method will be removed in a future major version.", false)] +protected virtual DbSet GetCasbinRuleDbSet(TDbContext dbContext) +{ + return GetCasbinRuleDbSet(dbContext, null); +} + +// New signature - allows policy-type-aware customization +protected virtual DbSet GetCasbinRuleDbSet(DbContext dbContext, string policyType) +{ + return dbContext.Set(); +} +``` + +**Rationale:** +- Old method is `protected virtual` - external code may override it +- Cannot remove without breaking change +- New signature enables policy-type-specific customization +- Old signature delegates to new one for compatibility + +#### DbSet Caching + +```csharp +private readonly Dictionary<(DbContext context, string policyType), DbSet> _persistPoliciesByContext; + +private DbSet GetCasbinRuleDbSetForPolicyType(DbContext context, string policyType) +{ + var key = (context, policyType); + if (!_persistPoliciesByContext.TryGetValue(key, out var dbSet)) + { + dbSet = GetCasbinRuleDbSet(context, policyType); + _persistPoliciesByContext[key] = dbSet; + } + return dbSet; +} +``` + +**Memory Characteristics:** +- Dictionary caches DbSet instances to avoid repeated `dbContext.Set()` reflection calls +- Typical memory: 224 bytes (2 contexts × 2 policy types = 4 entries) +- Worst-case: ~3.5 KB (8 contexts × 8 policy types = 64 entries) +- Bounded growth: (# contexts × # policy types), stable after warm-up +- See MULTI_CONTEXT_USAGE_GUIDE.md for detailed memory analysis + +### Operation Handling + +#### SavePolicy (Multi-Context with Adaptive Transactions) + +Most complex operation - coordinates across all contexts: + +```csharp +// Pseudocode +public virtual void SavePolicy(IPolicyStore store) +{ + var persistPolicies = store.ReadPolicyFromCasbinModel(); + var policiesByContext = persistPolicies.GroupBy(p => GetContextForPolicyType(p.Type)); + var contexts = GetAllContexts().Distinct().ToList(); + + if (contexts.Count == 1 || CanShareTransaction(contexts)) + { + // Use shared transaction (atomic) + SavePolicyWithSharedTransaction(contexts, policiesByContext); + } + else + { + // Use individual transactions (not atomic) + SavePolicyWithIndividualTransactions(contexts, policiesByContext); + } +} +``` + +#### LoadPolicy (Multi-Context, Read-Only) + +No transaction needed: + +```csharp +// Pseudocode +public virtual void LoadPolicy(IPolicyStore store) +{ + var allPolicies = new List(); + + foreach (var context in GetAllContexts().Distinct()) + { + var dbSet = GetCasbinRuleDbSet(context, null); + var policies = dbSet.AsNoTracking().ToList(); + allPolicies.AddRange(policies); + } + + store.LoadPolicyFromPersistPolicy(allPolicies); + IsFiltered = false; +} +``` + +#### AddPolicy/RemovePolicy (Single Context) + +Simple routing to appropriate context: + +```csharp +// Pseudocode +public virtual void AddPolicy(string section, string policyType, IPolicyValues values) +{ + var context = GetContextForPolicyType(policyType); + var dbSet = GetCasbinRuleDbSetForPolicyType(context, policyType); + + // Add policy to dbSet + context.SaveChanges(); +} +``` + +#### UpdatePolicy (Single Context with Transaction) + +```csharp +// Pseudocode +public void UpdatePolicy(string section, string policyType, IPolicyValues oldValues, IPolicyValues newValues) +{ + var context = GetContextForPolicyType(policyType); + using var transaction = context.Database.BeginTransaction(); + + try + { + RemovePolicy(context, section, policyType, oldValues); + AddPolicy(context, section, policyType, newValues); + context.SaveChanges(); + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } +} +``` + +## Implementation Decisions + +### 1. Runtime Detection vs. Validation + +**Decision:** Implement runtime detection without throwing errors + +**Rationale:** +- Allows flexible configurations (testing with separate files) +- Graceful degradation to individual transactions +- No breaking changes for existing code +- Users responsible for understanding trade-offs + +**Alternative Considered:** Throw exception if connection strings don't match +**Rejected Because:** Too restrictive for testing scenarios + +### 2. Schema-Based Provider + +**Decision:** Not implement in core library + +**Rationale:** +- Users can easily implement custom providers +- Keeps adapter focused and flexible +- Example implementations provided in documentation + +**Alternative Considered:** Include `SchemaBasedContextProvider` in library +**Rejected Because:** Too opinionated, users have varying needs + +### 3. Virtual Method Enhancement + +**Decision:** Add `policyType` parameter to `GetCasbinRuleDbSet()` + +**Rationale:** +- Enables advanced customization scenarios +- Maintains backward compatibility via `[Obsolete]` attribute +- External code overriding old method continues to work + +### 4. Database Initialization + +**Issue:** `EnsureCreated()` unreliable across EF Core versions + +**Solution:** +- Explicit model initialization: `_ = dbContext.Model;` +- Fallback: delete and recreate if table doesn't exist +- Applied in test fixtures and extension methods + +### 5. SQLite Transaction Limitation + +**Discovery:** `UseTransaction()` fails for separate SQLite files with "transaction not associated with connection" + +**Root Cause:** Each SQLite file has its own connection + +**Solution:** Adaptive transaction handling based on `CanShareTransaction()` + +**Impact:** Tests use separate files for isolation but accept non-atomic behavior + +## Performance & Limitations + +### Performance Overhead + +Multiple contexts incur: +- Additional connection management overhead +- Context switching costs +- Multiple `SaveChanges()` calls per operation +- Negligible memory overhead (~224 bytes to 3.5 KB for caching) + +### Limitations + +**Transaction-Related:** +1. SQLite separate files cannot share transactions +2. Same connection string required for atomicity +3. No cross-database or cross-server support +4. No distributed transaction coordination (DTC) + +**General:** +1. Users responsible for schema management and migrations +2. Error handling complexity with individual transactions +3. Partial failures possible when transaction sharing unavailable + +### AutoSave Mode and Transaction Atomicity + +The Casbin Enforcer's `EnableAutoSave` setting fundamentally affects transaction atomicity in multi-context scenarios. + +**AutoSave ON (Default Behavior):** + +When AutoSave is enabled, the Casbin Enforcer immediately calls the adapter's Add/Remove/Update methods for each operation. The adapter then calls `DbContext.SaveChangesAsync()`, which creates an implicit transaction for that single operation. + +**Code Flow:** +1. User calls `enforcer.AddPolicyAsync("alice", "data1", "read")` +2. Enforcer immediately calls `adapter.AddPolicyAsync(...)` +3. Adapter calls `context.SaveChangesAsync()` → commits to database +4. Returns to user + +**Implications:** +- Each operation is atomic in isolation +- **No transaction coordination across multiple operations** +- If a sequence of operations fails partway through, earlier operations remain committed +- The adapter's `SavePolicyAsync()` transaction coordination is bypassed entirely + +**AutoSave OFF (Batch Mode):** + +When AutoSave is disabled, operations stay in the Enforcer's in-memory policy store. Only when `SavePolicyAsync()` is called does the adapter receive all policies at once, enabling atomic transaction coordination. + +**Code Flow:** +1. User calls `enforcer.AddPolicyAsync("alice", "data1", "read")` → stored in memory +2. User calls `enforcer.AddGroupingPolicyAsync("alice", "admin")` → stored in memory +3. User calls `enforcer.SavePolicyAsync()` +4. Adapter receives ALL policies and uses shared transaction +5. All contexts commit atomically or all roll back + +**Design Implication:** + +The adapter **cannot** provide cross-context atomicity when AutoSave is ON because it never receives multiple policies in a single method call. Transaction coordination requires all policies to be processed together in `SavePolicyAsync()`. + +**Rollback Test Evidence:** + +The integration tests `SavePolicy_WhenTableDroppedInOneContext_ShouldRollbackAllContexts` and `SavePolicy_WhenTableMissingInOneContext_ShouldRollbackAllContexts` originally failed because they used `AddPolicyAsync()` with AutoSave ON (default). This caused policies to commit immediately, preventing rollback verification. + +**Fix:** Adding `enforcer.EnableAutoSave(false)` at lines 302 and 370 in `TransactionIntegrityTests.cs` fixed the tests by ensuring policies stayed in memory until `SavePolicyAsync()` was called, allowing proper atomic rollback testing. + +**Code Evidence:** +```csharp +// TransactionIntegrityTests.cs:302, 370 +try +{ + // Disable AutoSave so policies stay in-memory until SavePolicyAsync() is called + enforcer.EnableAutoSave(false); + + // Add policies to all contexts (in memory only, AutoSave is OFF) + await enforcer.AddPolicyAsync("alice", "data1", "read"); + await enforcer.AddGroupingPolicyAsync("alice", "admin"); + await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser"); + + // Simulate failure (e.g., drop table in one context) + await _fixture.DropTableAsync(TransactionIntegrityTestFixture.RolesSchema); + + // Try to save - should fail and rollback ALL contexts atomically + await adapter.SavePolicyAsync(enforcer.GetModel()); + + // Verify all contexts rolled back to 0 policies (atomicity verified) +} +``` + +**Recommendation:** + +For multi-context scenarios requiring atomicity: +1. Use `enforcer.EnableAutoSave(false)` +2. Ensure all contexts share the same `DbConnection` object +3. Call `SavePolicyAsync()` to batch commit atomically + +**Reference:** See [MULTI_CONTEXT_USAGE_GUIDE.md](MULTI_CONTEXT_USAGE_GUIDE.md#enableautosave-and-transaction-atomicity) for detailed user guidance and examples. + +### When to Use Multi-Context + +**Good Use Cases:** +- Separate policy and grouping data for compliance +- Multi-tenant routing with tenant-specific contexts +- Organizational separation of concerns + +**Not Recommended For:** +- Cross-database scenarios requiring atomicity +- Simple authorization models (single context sufficient) + +## Verification + +### Integration Tests + +Transaction integrity guarantees are verified by comprehensive integration tests in: +- **[TransactionIntegrityTests.cs](../Casbin.Persist.Adapter.EFCore.UnitTest/Integration/TransactionIntegrityTests.cs)** - Proves atomic commit/rollback across multiple contexts + +**Test Coverage:** + +| Test | Purpose | What It Proves | +|------|---------|----------------| +| `SavePolicy_WithSharedConnection_ShouldWriteToAllContextsAtomically` | Happy path atomic write | Policies written to 3 schemas in single transaction | +| `MultiContextSetup_WithSharedConnection_ShouldShareSamePhysicalConnection` | Connection sharing | Reference equality check confirms shared DbConnection object | +| `SavePolicy_WhenDuplicateKeyViolationInOneContext_ShouldRollbackAllContexts` | **CRITICAL** Rollback on constraint violation | Failure in one context rolls back all contexts atomically | +| `SavePolicy_WhenTableMissingInOneContext_ShouldRollbackAllContexts` | Rollback on severe failure | Missing table in one context rolls back all contexts | +| `MultipleSaveOperations_WithSharedConnection_ShouldMaintainDataConsistency` | Consistency over time | Multiple incremental saves maintain integrity | +| `SavePolicy_WithSeparateConnections_ShouldNotBeAtomic` | **Negative test** | Proves separate connections do NOT provide atomicity | +| `SavePolicy_ShouldReflectDatabaseStateNotCasbinMemory` | Database verification | Tests verify actual database state, not just Casbin memory | + +**Running Integration Tests:** + +```bash +# Run all integration tests locally +dotnet test --filter "Category=Integration" + +# Run specific test +dotnet test --filter "FullyQualifiedName~SavePolicy_WhenDuplicateKeyViolationInOneContext_ShouldRollbackAllContexts" +``` + +**Note:** Integration tests are excluded from CI/CD (marked with `[Trait("Category", "Integration")]`) as they: +- Require local PostgreSQL database +- Take longer to execute than unit tests +- Are specific to multi-context functionality validation + +### Test Architecture + +**Setup:** +- Uses local PostgreSQL database for testing (database `casbin_integration_test` must exist) +- Creates 3 separate schemas: `casbin_policies`, `casbin_groupings`, `casbin_roles` +- Routes policy types: p → policies, g → groupings, g2 → roles +- Simulates real multi-context scenarios + +**Prerequisites to run integration tests:** +- PostgreSQL running on localhost:5432 +- Database `casbin_integration_test` must exist (schemas created automatically) +- Connection credentials: postgres/postgres (or update ConnectionString in fixture) + +**Failure Simulation:** +- Duplicate key violations (via direct SQL INSERT) +- Missing tables (via DROP TABLE) +- Separate connection objects (to prove non-atomicity) + +**Verification:** +- Raw SQL queries to count policies in each schema +- Reference equality checks on DbConnection objects +- Database state verification (not just Casbin in-memory state) + +## Status + +**Implementation:** ✅ Complete +**Testing:** ✅ All 120 unit tests passing (30 tests × 4 frameworks) + 7 integration tests +**Documentation:** ✅ Complete +**Breaking Changes:** None - fully backward compatible + +## See Also + +- [MULTI_CONTEXT_USAGE_GUIDE.md](MULTI_CONTEXT_USAGE_GUIDE.md) - Step-by-step user guide +- [ICasbinDbContextProvider Interface](Casbin.Persist.Adapter.EFCore/ICasbinDbContextProvider.cs) - Interface source code +- [EFCoreAdapter Implementation](Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs) - Adapter source code +- [TransactionIntegrityTests.cs](../Casbin.Persist.Adapter.EFCore.UnitTest/Integration/TransactionIntegrityTests.cs) - Integration test suite diff --git a/MULTI_CONTEXT_USAGE_GUIDE.md b/MULTI_CONTEXT_USAGE_GUIDE.md new file mode 100644 index 0000000..18d0a71 --- /dev/null +++ b/MULTI_CONTEXT_USAGE_GUIDE.md @@ -0,0 +1,585 @@ +# Multi-Context Support Usage Guide + +## Overview + +Multi-context support allows you to store different Casbin policy types in separate database locations while maintaining a unified authorization model. + +**Use cases:** +- Store policy rules (p, p2) and role assignments (g, g2) in separate schemas +- Apply different retention policies per policy type +- Separate concerns in multi-tenant systems + +**How it works:** +- Each `CasbinDbContext` targets a different schema, table, or database +- A context provider routes policy types to the appropriate context +- The adapter automatically coordinates operations across all contexts + +## Quick Start + +### Step 1: Create Database Contexts + +Create separate `CasbinDbContext` instances that **share the same physical DbConnection object**. + +**⚠️ CRITICAL - Shared Connection Requirement:** + +For atomic transactions across contexts, you MUST pass the **same DbConnection object instance** to all contexts. EF Core's `UseTransaction()` requires reference equality of connection objects, not just matching connection strings. + +**✅ CORRECT: Share physical DbConnection object** + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Data.SqlClient; // or Npgsql.NpgsqlConnection, etc. +using Casbin.Persist.Adapter.EFCore; + +// Create ONE shared connection object +string connectionString = "Server=localhost;Database=CasbinDB;Trusted_Connection=True;"; +var sharedConnection = new SqlConnection(connectionString); + +// Pass SAME connection instance to both contexts +var policyContext = new CasbinDbContext( + new DbContextOptionsBuilder>() + .UseSqlServer(sharedConnection) // ← Shared connection object + .Options, + schemaName: "policies"); +policyContext.Database.EnsureCreated(); + +var groupingContext = new CasbinDbContext( + new DbContextOptionsBuilder>() + .UseSqlServer(sharedConnection) // ← Same connection object + .Options, + schemaName: "groupings"); +groupingContext.Database.EnsureCreated(); +``` + +**❌ WRONG: This will NOT provide atomic transactions** + +```csharp +// Each .UseSqlServer(connectionString) creates a DIFFERENT DbConnection object +var policyContext = new CasbinDbContext( + new DbContextOptionsBuilder>() + .UseSqlServer(connectionString) // ← Creates DbConnection #1 + .Options); + +var groupingContext = new CasbinDbContext( + new DbContextOptionsBuilder>() + .UseSqlServer(connectionString) // ← Creates DbConnection #2 (different object!) + .Options); + +// These contexts have different connection objects, so they CANNOT share transactions +``` + +**Other configuration options:** + +| Option | Use Case | Example | +|--------|----------|---------| +| **Different schemas** | SQL Server, PostgreSQL | `schemaName: "policies"` vs `schemaName: "groupings"` | +| **Different tables** | Any database | `tableName: "casbin_policy"` vs `tableName: "casbin_grouping"` | +| **Separate databases** | Testing only | `UseSqlite("policy.db")` vs `UseSqlite("grouping.db")` ⚠️ Not atomic | + +### Step 2: Implement Context Provider + +Create a provider that routes policy types to contexts: + +```csharp +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Casbin.Persist.Adapter.EFCore; + +public class PolicyTypeContextProvider : ICasbinDbContextProvider +{ + private readonly CasbinDbContext _policyContext; + private readonly CasbinDbContext _groupingContext; + + public PolicyTypeContextProvider( + CasbinDbContext policyContext, + CasbinDbContext groupingContext) + { + _policyContext = policyContext ?? throw new ArgumentNullException(nameof(policyContext)); + _groupingContext = groupingContext ?? throw new ArgumentNullException(nameof(groupingContext)); + } + + public DbContext GetContextForPolicyType(string policyType) + { + if (string.IsNullOrEmpty(policyType)) + return _policyContext; + + // Route: p/p2/p3 → policyContext, g/g2/g3 → groupingContext + return policyType.StartsWith("p", StringComparison.OrdinalIgnoreCase) + ? _policyContext + : _groupingContext; + } + + public IEnumerable GetAllContexts() + { + return new DbContext[] { _policyContext, _groupingContext }; + } +} +``` + +**Policy type routing:** + +| Policy Type | Context | Description | +|-------------|---------|-------------| +| `p`, `p2`, `p3`, ... | policyContext | Permission rules | +| `g`, `g2`, `g3`, ... | groupingContext | Role/group assignments | + +### Step 3-4: Create Adapter and Enforcer + +```csharp +// Create provider +var provider = new PolicyTypeContextProvider(policyContext, groupingContext); + +// Create adapter with multi-context support +var adapter = new EFCoreAdapter(provider); + +// Create enforcer (multi-context behavior is transparent) +var enforcer = new Enforcer("path/to/model.conf", adapter); +enforcer.LoadPolicy(); +``` + +### Step 5: Use Normally + +```csharp +// Add policies (automatically routed to correct contexts) +enforcer.AddPolicy("alice", "data1", "read"); // → policyContext +enforcer.AddGroupingPolicy("alice", "admin"); // → groupingContext + +// Save (coordinated across both contexts) +enforcer.SavePolicy(); + +// Check permissions (combines data from both contexts) +bool allowed = enforcer.Enforce("alice", "data1", "read"); +``` + +### Complete Example + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Data.SqlClient; +using NetCasbin; +using Casbin.Persist.Adapter.EFCore; + +public class Program +{ + public static void Main() + { + // 1. Create shared connection object + string connectionString = "Server=localhost;Database=CasbinDB;Trusted_Connection=True;"; + var sharedConnection = new SqlConnection(connectionString); + + // 2. Create contexts with shared connection + var policyContext = new CasbinDbContext( + new DbContextOptionsBuilder>() + .UseSqlServer(sharedConnection).Options, // ← Shared connection + schemaName: "policies"); + policyContext.Database.EnsureCreated(); + + var groupingContext = new CasbinDbContext( + new DbContextOptionsBuilder>() + .UseSqlServer(sharedConnection).Options, // ← Same connection + schemaName: "groupings"); + groupingContext.Database.EnsureCreated(); + + // 3. Create provider (use implementation from Step 2) + var provider = new PolicyTypeContextProvider(policyContext, groupingContext); + + // 4. Create adapter and enforcer + var adapter = new EFCoreAdapter(provider); + var enforcer = new Enforcer("rbac_model.conf", adapter); + + // 5. Use enforcer (atomic transactions across both contexts) + enforcer.AddPolicy("alice", "data1", "read"); + enforcer.AddGroupingPolicy("alice", "admin"); + enforcer.SavePolicy(); + + bool allowed = enforcer.Enforce("alice", "data1", "read"); + Console.WriteLine($"Alice can read data1: {allowed}"); + + // 6. Cleanup + sharedConnection.Dispose(); + } +} +``` + +## Configuration Reference + +### Async Operations + +All operations have async variants: + +```csharp +await enforcer.AddPolicyAsync("alice", "data1", "read"); +await enforcer.AddGroupingPolicyAsync("alice", "admin"); +await enforcer.SavePolicyAsync(); +await enforcer.LoadPolicyAsync(); +``` + +### Filtered Loading + +Load subsets of policies across all contexts: + +```csharp +enforcer.LoadFilteredPolicy(new Filter +{ + P = new[] { "alice", "", "" }, // Only Alice's policies + G = new[] { "alice", "" } // Only Alice's groupings +}); +``` + +### Dependency Injection + +For ASP.NET Core applications with shared connection: + +```csharp +// Register shared connection as singleton +services.AddSingleton(sp => +{ + var connectionString = Configuration.GetConnectionString("Casbin"); + return new SqlConnection(connectionString); +}); + +// Register context provider with shared connection +services.AddSingleton>(sp => +{ + var sharedConnection = sp.GetRequiredService(); + + var policyCtx = new CasbinDbContext( + new DbContextOptionsBuilder>() + .UseSqlServer(sharedConnection).Options, // Shared connection + schemaName: "policies"); + + var groupingCtx = new CasbinDbContext( + new DbContextOptionsBuilder>() + .UseSqlServer(sharedConnection).Options, // Same connection + schemaName: "groupings"); + + return new PolicyTypeContextProvider(policyCtx, groupingCtx); +}); + +services.AddSingleton(sp => +{ + var provider = sp.GetRequiredService>(); + return new EFCoreAdapter(provider); +}); + +services.AddSingleton(sp => +{ + var adapter = sp.GetRequiredService(); + return new Enforcer("rbac_model.conf", adapter); +}); +``` + +### Connection Lifetime Management + +**Important:** When using shared connections, you are responsible for connection lifetime: + +**In simple applications:** +```csharp +// Create connection +var connection = new SqlConnection(connectionString); + +// Use for contexts/adapter/enforcer +// ... (create contexts, adapter, enforcer) + +// Dispose when done +connection.Dispose(); +``` + +**With using statement:** +```csharp +using (var connection = new SqlConnection(connectionString)) +{ + // Create contexts with shared connection + var policyCtx = new CasbinDbContext(...); + var groupingCtx = new CasbinDbContext(...); + + // Create and use enforcer + var provider = new PolicyTypeContextProvider(policyCtx, groupingCtx); + var adapter = new EFCoreAdapter(provider); + var enforcer = new Enforcer("model.conf", adapter); + + enforcer.LoadPolicy(); + // ... use enforcer + +} // Connection disposed automatically +``` + +**In DI scenarios:** + +The DbConnection is registered as a singleton and will be disposed when the application shuts down. No manual disposal needed in request handlers. + +## Transaction Behavior + +### Shared Connection Requirements + +**For atomic transactions across contexts, all contexts MUST share the same DbConnection object instance.** + +**How atomic transactions work:** +1. You create ONE DbConnection object and pass it to all contexts +2. Adapter detects shared connection via `CanShareTransaction()` (reference equality check) +3. Adapter uses `UseTransaction()` to enlist all contexts in one transaction +4. Database ensures atomic commit/rollback across both contexts + +**✅ CORRECT Example:** + +Already shown in Step 1 - create shared DbConnection and pass to all contexts. + +### EnableAutoSave and Transaction Atomicity + +The Casbin Enforcer's `EnableAutoSave` setting fundamentally affects transaction atomicity in multi-context scenarios. + +#### Understanding AutoSave Modes + +**EnableAutoSave(true) - Immediate Commits (Default)** + +When AutoSave is enabled (the default), each `AddPolicy`/`RemovePolicy`/`UpdatePolicy` operation commits immediately to the database. + +**Behavior:** +- Each individual operation is fully atomic (succeeds or fails completely) +- Each operation creates its own implicit database transaction +- **No atomicity across multiple operations:** + - If you execute 3 operations sequentially and the 3rd fails, the first 2 remain committed + - Earlier operations cannot be rolled back when later operations fail + - Each operation is independent + +**Use Cases:** +- Real-time policy updates where each change is independent +- Single-context usage where cross-context atomicity isn't required +- Scenarios where you can tolerate some operations committing while others don't + +**Example - Independent Commits:** +```csharp +var enforcer = new Enforcer(model, adapter); +enforcer.EnableAutoSave(true); // Default behavior + +// Each operation commits immediately and independently: +await enforcer.AddPolicyAsync("alice", "data1", "read"); // ← Commits to DB now +await enforcer.AddGroupingPolicyAsync("alice", "admin"); // ← Commits to DB now +await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "super"); // ← If this fails... + +// ⚠️ The first 2 operations are already committed and CANNOT be rolled back +``` + +**EnableAutoSave(false) - Batched Atomic Commits** + +When AutoSave is disabled, all operations stay in memory until `enforcer.SavePolicyAsync()` is called. + +**Behavior:** +- Operations stored in Casbin's in-memory policy store (not database) +- When `SavePolicyAsync()` is called with shared connection: + - All contexts enlisted in single connection-level transaction + - All operations commit atomically (all-or-nothing) + - If any operation fails, entire transaction rolls back +- **Full atomicity across all operations** + +**Use Cases:** +- Multi-context scenarios requiring atomicity +- Batch policy updates that must succeed or fail together +- Critical operations where partial application is unacceptable +- Production systems with ACID requirements + +**Example - Atomic Batch Commit:** +```csharp +var enforcer = new Enforcer(model, adapter); +enforcer.EnableAutoSave(false); // Disable AutoSave for atomicity + +// All operations stay in memory (not committed yet): +await enforcer.AddPolicyAsync("alice", "data1", "read"); // In memory only +await enforcer.AddGroupingPolicyAsync("alice", "admin"); // In memory only +await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "super"); // In memory only + +// Commit all operations atomically (all-or-nothing): +await enforcer.SavePolicyAsync(); // ← All 3 commit together OR all 3 roll back + +// ✅ Either all 3 policies exist in database, or none do +``` + +#### Recommendation for Multi-Context Atomicity + +> **💡 Best Practice** +> +> When using multiple contexts and you need all policy changes to succeed or fail together: +> +> 1. **Disable AutoSave:** `enforcer.EnableAutoSave(false)` +> 2. **Use shared connection:** Ensure all contexts share the same `DbConnection` object (see above) +> 3. **Batch commit:** Call `await enforcer.SavePolicyAsync()` to commit atomically +> +> This ensures all policy changes across all contexts are committed atomically or rolled back together. + +#### Real-World Example: Authorization Setup + +**Scenario:** Setting up a new user with permissions and role assignments. + +**Without Atomicity (AutoSave ON - Default):** +```csharp +// AutoSave is ON by default +await enforcer.AddPolicyAsync("bob", "data1", "read"); // ✓ Committed to policies schema +await enforcer.AddPolicyAsync("bob", "data1", "write"); // ✓ Committed to policies schema +await enforcer.AddGroupingPolicyAsync("bob", "admin"); // ✗ FAILS - network error + +// Problem: Bob has partial permissions (read/write) but no admin role +// Result: Inconsistent authorization state +``` + +**With Atomicity (AutoSave OFF):** +```csharp +enforcer.EnableAutoSave(false); // Require explicit save + +await enforcer.AddPolicyAsync("bob", "data1", "read"); // In memory +await enforcer.AddPolicyAsync("bob", "data1", "write"); // In memory +await enforcer.AddGroupingPolicyAsync("bob", "admin"); // In memory + +try +{ + await enforcer.SavePolicyAsync(); // Atomic commit - all or nothing + // ✓ Success: All 3 policies committed +} +catch (Exception ex) +{ + // ✓ Failure: All 3 policies rolled back automatically + // Result: Bob has no permissions (consistent state) + Console.WriteLine($"Setup failed: {ex.Message}"); +} +``` + +#### Technical Details + +**How AutoSave Affects Transaction Coordination:** + +With **AutoSave ON**, the Casbin Enforcer immediately calls the adapter's methods for each operation. The adapter has no opportunity to coordinate transactions because it receives operations one at a time. + +**Call Flow (AutoSave ON):** +``` +User: enforcer.AddPolicyAsync() + → Enforcer: Immediately calls adapter.AddPolicyAsync() + → Adapter: context.SaveChangesAsync() → Database (committed) + → Returns to user +``` + +With **AutoSave OFF**, operations accumulate in memory. Only when `SavePolicyAsync()` is called does the adapter receive all policies at once, enabling atomic transaction coordination. + +**Call Flow (AutoSave OFF):** +``` +User: enforcer.AddPolicyAsync() + → Enforcer: Stores in memory, does NOT call adapter + → Returns to user + +User: enforcer.SavePolicyAsync() + → Enforcer: Calls adapter.SavePolicyAsync() with ALL policies + → Adapter: Starts shared transaction + → Adapter: Enlists all contexts in transaction + → Adapter: Commits/clears all contexts + → Adapter: Commits transaction atomically + → Returns to user +``` + +**For More Details:** See [Integration Test README](Casbin.Persist.Adapter.EFCore.UnitTest/Integration/README.md) for test evidence of this behavior, particularly the rollback tests that require `EnableAutoSave(false)`. + +### Context Factory Pattern (Recommended) + +```csharp +public class CasbinContextFactory : IDisposable +{ + private readonly DbConnection _sharedConnection; + + public CasbinContextFactory(IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("Casbin"); + _sharedConnection = new SqlConnection(connectionString); // Create shared connection once + } + + public CasbinDbContext CreateContext(string schemaName) + { + var options = new DbContextOptionsBuilder>() + .UseSqlServer(_sharedConnection) // ← Share same connection object + .Options; + return new CasbinDbContext(options, schemaName: schemaName); + } + + public void Dispose() + { + _sharedConnection?.Dispose(); + } +} + +// Usage +using var factory = new CasbinContextFactory(configuration); +var policyContext = factory.CreateContext("policies"); +var groupingContext = factory.CreateContext("groupings"); +// Both contexts share the same physical connection object +``` + +### Database Compatibility + +| Database | Atomic Transactions | Connection Requirement | Notes | +|----------|-------------------|----------------------|-------| +| **SQL Server** | ✅ Yes | Same DbConnection object | Works with different schemas/tables | +| **PostgreSQL** | ✅ Yes | Same DbConnection object | Works with different schemas/tables | +| **MySQL** | ✅ Yes | Same DbConnection object | Works with different schemas/tables | +| **SQLite** | ✅ Yes | Same DbConnection object | Works with different tables in same file | + +**Note:** "Same database" requires **same DbConnection object instance**, not just matching connection strings. + +### Responsibility Matrix + +| Task | Your Responsibility | Adapter Responsibility | +|------|-------------------|----------------------| +| Create shared DbConnection object | ✅ YES | ❌ NO | +| Pass same connection to all contexts | ✅ YES | ❌ NO | +| Manage connection lifetime | ✅ YES | ❌ NO | +| Use context factory pattern | ✅ YES (recommended) | ❌ NO | +| Call `UseTransaction()` | ❌ NO | ✅ YES (internal) | +| Detect shared connection (reference equality) | ❌ NO | ✅ YES | +| Coordinate commit/rollback | ❌ NO | ✅ YES | + +### When Separate Connections Are Acceptable + +**Non-atomic behavior (individual transactions per context) may be acceptable for:** +- Testing and development +- Read-heavy workloads with eventual consistency +- Non-critical data + +**Not acceptable for:** +- Production ACID requirements (financial, authorization) +- Compliance/audit scenarios +- Multi-tenant SaaS with strict data integrity + +## Troubleshooting + +### "No such table" errors + +**Cause:** Database tables not created. + +**Solution:** +```csharp +policyContext.Database.EnsureCreated(); +groupingContext.Database.EnsureCreated(); +``` + +### Partial data committed on failure + +**Cause:** Using separate database connections (e.g., different SQLite files). + +**Solution:** Use same database with different schemas/tables: +```csharp +// Instead of separate files +.UseSqlite("Data Source=policy.db") +.UseSqlite("Data Source=grouping.db") + +// Use same file with different tables +.UseSqlite("Data Source=casbin.db") // Both use same file +// Configure different table names +``` + +### Transaction warnings in logs + +**Cause:** Adapter detected different connection strings and fell back to individual transactions. + +**Solution:** Ensure all contexts use the same connection string variable (see [Context Factory Pattern](#context-factory-pattern-recommended)). + +## See Also + +- [MULTI_CONTEXT_DESIGN.md](MULTI_CONTEXT_DESIGN.md) - Technical architecture and implementation details +- [Casbin.NET Documentation](https://casbin.org/docs/overview) - Casbin concepts and model syntax +- [ICasbinDbContextProvider Interface](Casbin.Persist.Adapter.EFCore/ICasbinDbContextProvider.cs) - Interface definition diff --git a/README.md b/README.md index c944c2c..49fb6bb 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,63 @@ This approach resolves the DbContext from the service provider on each database - No `ObjectDisposedException` is thrown when the adapter outlives the scope that created it - The adapter can be used in long-lived services like singletons +## Multi-Context Support + +The adapter supports storing different policy types in separate database contexts, allowing you to: +- Store policies (p, p2, etc.) and groupings (g, g2, etc.) in different schemas +- Use different tables for different policy types +- Separate data for multi-tenant or compliance scenarios + +### Quick Example + +```csharp +// Create ONE shared connection object +var sharedConnection = new SqlConnection(connectionString); + +// Create contexts with shared connection +var policyContext = new CasbinDbContext( + new DbContextOptionsBuilder>() + .UseSqlServer(sharedConnection).Options, // Shared connection + schemaName: "policies"); + +var groupingContext = new CasbinDbContext( + new DbContextOptionsBuilder>() + .UseSqlServer(sharedConnection).Options, // Same connection + schemaName: "groupings"); + +// Create a provider that routes policy types to contexts +var provider = new PolicyTypeContextProvider(policyContext, groupingContext); + +// Use the provider with the adapter +var adapter = new EFCoreAdapter(provider); +var enforcer = new Enforcer("rbac_model.conf", adapter); + +// All operations work transparently across contexts +enforcer.AddPolicy("alice", "data1", "read"); // → policyContext +enforcer.AddGroupingPolicy("alice", "admin"); // → groupingContext +enforcer.SavePolicy(); // Atomic across both +``` + +> **⚠️ Transaction Integrity Requirements** +> +> For atomic multi-context operations: +> 1. **Share DbConnection:** All contexts must use the **same `DbConnection` object** (reference equality) +> 2. **Disable AutoSave:** Use `enforcer.EnableAutoSave(false)` and call `SavePolicyAsync()` to batch commit +> 3. **Supported databases:** PostgreSQL, MySQL, SQL Server, SQLite (same file) +> +> **Why disable AutoSave?** With `EnableAutoSave(true)` (default), each policy operation commits immediately and independently. If a later operation fails, earlier operations remain committed. With `EnableAutoSave(false)`, all changes stay in memory until `SavePolicyAsync()` commits them atomically across all contexts using a shared connection-level transaction. +> +> - ✅ **Atomic:** Same `DbConnection` object + `EnableAutoSave(false)` + `SavePolicyAsync()` +> - ❌ **Not Atomic:** AutoSave ON, separate `DbConnection` objects, different databases +> +> See detailed explanation in [EnableAutoSave and Transaction Atomicity](MULTI_CONTEXT_USAGE_GUIDE.md#enableautosave-and-transaction-atomicity). + +### Documentation + +- **[Multi-Context Usage Guide](MULTI_CONTEXT_USAGE_GUIDE.md)** - Complete step-by-step guide with examples +- **[Multi-Context Design](MULTI_CONTEXT_DESIGN.md)** - Detailed design documentation and limitations +- **[Integration Tests Setup](Casbin.Persist.Adapter.EFCore.UnitTest/Integration/README.md)** - How to run transaction integrity tests locally + ## Getting Help - [Casbin.NET](https://github.com/casbin/Casbin.NET) diff --git a/TEST_SUMMARY.md b/TEST_SUMMARY.md new file mode 100644 index 0000000..ad33a02 --- /dev/null +++ b/TEST_SUMMARY.md @@ -0,0 +1,177 @@ +# Multi-Context Test Suite Summary + +This document summarizes the comprehensive test suite created for the multi-context functionality. + +## Files Created + +### Core Implementation Files +1. **ICasbinDbContextProvider.cs** - Interface for context providers +2. **SingleContextProvider.cs** - Default single-context implementation (backward compatible) + +### Test Infrastructure +3. **PolicyTypeContextProvider.cs** - Test provider routing 'p' types to one context, 'g' types to another +4. **MultiContextProviderFixture.cs** - xUnit fixture for multi-context test setup + +### Test Files +5. **MultiContextTest.cs** - 17 comprehensive tests for multi-context scenarios +6. **BackwardCompatibilityTest.cs** - 12 tests ensuring existing code still works + +## Test Coverage + +### MultiContextTest.cs (17 Tests) + +#### CRUD Operations +- **TestMultiContextAddPolicy** - Verify policies route to correct contexts +- **TestMultiContextAddPolicyAsync** - Async version of add policy +- **TestMultiContextRemovePolicy** - Verify removal from correct contexts +- **TestMultiContextUpdatePolicy** - Verify updates in correct context + +#### Load/Save Operations +- **TestMultiContextLoadPolicy** - Load policies from multiple contexts +- **TestMultiContextLoadPolicyAsync** - Async load from multiple contexts +- **TestMultiContextSavePolicy** - Save and distribute policies to correct contexts +- **TestMultiContextSavePolicyAsync** - Async save across contexts + +#### Batch Operations +- **TestMultiContextBatchOperations** - Add/remove multiple policies across contexts + +#### Filtering +- **TestMultiContextLoadFilteredPolicy** - Filter policies from multiple contexts + +#### Transaction Tests +- **TestMultiContextTransactionRollback** - Verify transaction integrity across contexts + +#### Provider Tests +- **TestMultiContextProviderGetAllContexts** - Verify GetAllContexts returns correct count +- **TestMultiContextProviderGetContextForPolicyType** - Verify routing logic for different policy types + +### BackwardCompatibilityTest.cs (12 Tests) + +#### Constructor Compatibility +- **TestSingleContextConstructorStillWorks** - Original constructor API still works +- **TestSingleContextAsyncOperationsStillWork** - Async operations work with single context + +#### Operations +- **TestSingleContextLoadAndSave** - Load/save in single context +- **TestSingleContextWithExistingTests** - Match patterns from existing AutoTest.cs +- **TestSingleContextRemoveOperations** - Remove policies in single context +- **TestSingleContextUpdateOperations** - Update policies in single context +- **TestSingleContextBatchOperations** - Batch add/remove in single context +- **TestSingleContextFilteredLoading** - Filtered loading in single context + +#### Provider Tests +- **TestSingleContextProviderWrapping** - SingleContextProvider behaves like direct constructor +- **TestSingleContextProviderGetAllContexts** - Returns single context +- **TestSingleContextProviderGetContextForPolicyType** - All types return same context + +## Test Scenarios Covered + +### Multi-Context Scenarios + +1. **Separate Tables**: Policies in `casbin_policy` table, groupings in `casbin_grouping` table +2. **Cross-Context Operations**: Operations that span multiple contexts +3. **Transaction Integrity**: Ensuring ACID properties across contexts +4. **Context Routing**: Verifying correct context selection based on policy type + +### Backward Compatibility Scenarios + +1. **Single Context Usage**: All existing patterns continue to work +2. **Original Constructor**: Direct context passing still works +3. **All Operations**: CRUD, batch, filtering all work as before +4. **Provider Wrapping**: Explicit SingleContextProvider matches implicit behavior + +## Important Notes + +### Tests Will Initially Fail + +⚠️ **These tests are written in TDD (Test-Driven Development) style.** They will fail until the actual multi-context implementation is added to `EFCoreAdapter.cs`. + +The tests define the expected behavior and API. Implementation needs to: + +1. Add `ICasbinDbContextProvider` field to EFCoreAdapter +2. Add new constructor accepting context provider +3. Modify all CRUD operations to route to correct context +4. Implement shared transaction logic for operations spanning multiple contexts +5. Update virtual methods to support context-aware behavior + +### Build Requirements + +The project targets multiple frameworks including .NET 9.0. To build: +- Install .NET 9.0 SDK, or +- Remove net9.0 from TargetFrameworks in .csproj files temporarily + +### Test Database + +Tests use SQLite with separate database files per test to avoid conflicts: +- Single context tests: `{testName}.db` +- Multi-context tests: `MultiContext_{testName}.db` with two tables + +## Running Tests + +Once implementation is complete: + +```bash +# Run all tests +dotnet test + +# Run only multi-context tests +dotnet test --filter "FullyQualifiedName~MultiContextTest" + +# Run only backward compatibility tests +dotnet test --filter "FullyQualifiedName~BackwardCompatibilityTest" + +# Run specific test +dotnet test --filter "FullyQualifiedName~TestMultiContextAddPolicy" +``` + +## Test Strategy + +### Phase 1: Provider Infrastructure ✅ +- [x] Create ICasbinDbContextProvider interface +- [x] Create SingleContextProvider implementation +- [x] Create test providers and fixtures + +### Phase 2: Write Tests ✅ +- [x] Multi-context CRUD tests +- [x] Multi-context transaction tests +- [x] Backward compatibility tests +- [x] Provider behavior tests + +### Phase 3: Implementation (Next Steps) +- [ ] Update EFCoreAdapter constructors +- [ ] Implement context routing logic +- [ ] Implement shared transaction handling +- [ ] Run tests and iterate until all pass + +### Phase 4: Integration +- [ ] Run existing AutoTest.cs to ensure no regressions +- [ ] Run all test suites together +- [ ] Update documentation with examples + +## Expected Test Results After Implementation + +After implementation is complete, all 29 tests should pass: +- ✅ 17 multi-context tests passing +- ✅ 12 backward compatibility tests passing +- ✅ All existing tests still passing (no regressions) + +## Key Test Assertions + +### Multi-Context Tests Verify: +- Policies route to correct contexts based on policy type +- All contexts participate in shared transactions +- Load operations merge data from all contexts +- Save operations distribute data to correct contexts +- Filtering works across multiple contexts + +### Backward Compatibility Tests Verify: +- Original constructor works identically +- Single context contains all policy types +- All operations produce same results as before +- SingleContextProvider behaves transparently + +--- + +**Document Status:** Test Suite Complete - Ready for Implementation +**Test Count:** 29 tests (17 multi-context + 12 backward compatibility) +**Last Updated:** 2025-10-14 From 7c3fffb52343bb8fe0122cb36c5f464026d41b9c Mon Sep 17 00:00:00 2001 From: Thor Arne Johansen Date: Wed, 12 Nov 2025 16:28:49 +0100 Subject: [PATCH 02/11] fix: Replace deprecated Filter class with IPolicyFilter implementation - Create SimpleFieldFilter test helper to replace obsolete Filter class - Update test files to use SimpleFieldFilter with Policy.ValuesFrom() - Update documentation with modern IPolicyFilter examples - Use xunit.runner.visualstudio 2.4.5 for .NET Core 3.1+ support - Add #nullable enable directives for nullable annotation context Eliminates all CS0618 warnings from deprecated Filter usage. All 300 tests passing across 6 target frameworks. --- ...sist.Adapter.EFCore.IntegrationTest.csproj | 72 ++ .../Integration/AutoSaveTests.cs | 984 ++++++++++++++++++ .../Integration/SchemaDistributionTests.cs | 342 ++++++ .../Integration/TransactionIntegrityTests.cs | 578 ++++++++++ .../BackwardCompatibilityTest.cs | 6 +- ...bin.Persist.Adapter.EFCore.UnitTest.csproj | 6 +- .../Fixtures/PolicyTypeContextProvider.cs | 2 + .../Fixtures/SimpleFieldFilter.cs | 36 + .../MultiContextTest.cs | 6 +- .../ICasbinDbContextProvider.cs | 2 + .../SingleContextProvider.cs | 2 + MULTI_CONTEXT_USAGE_GUIDE.md | 50 +- 12 files changed, 2072 insertions(+), 14 deletions(-) create mode 100644 Casbin.Persist.Adapter.EFCore.IntegrationTest/Casbin.Persist.Adapter.EFCore.IntegrationTest.csproj create mode 100644 Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/AutoSaveTests.cs create mode 100644 Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/SchemaDistributionTests.cs create mode 100644 Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTests.cs create mode 100644 Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/SimpleFieldFilter.cs diff --git a/Casbin.Persist.Adapter.EFCore.IntegrationTest/Casbin.Persist.Adapter.EFCore.IntegrationTest.csproj b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Casbin.Persist.Adapter.EFCore.IntegrationTest.csproj new file mode 100644 index 0000000..1ecf2d2 --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Casbin.Persist.Adapter.EFCore.IntegrationTest.csproj @@ -0,0 +1,72 @@ + + + + net9.0;net8.0;net7.0;net6.0;net5.0;netcoreapp3.1; + false + 11 + false + $(NoWarn);NU1701 + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Always + + + Always + + + Always + + + PreserveNewest + + + + diff --git a/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/AutoSaveTests.cs b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/AutoSaveTests.cs new file mode 100644 index 0000000..99ab71f --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/AutoSaveTests.cs @@ -0,0 +1,984 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Casbin.Model; +using Casbin.Persist.Adapter.EFCore.Entities; +using Npgsql; +using Microsoft.EntityFrameworkCore; +using Xunit; +using Xunit.Abstractions; + +#nullable enable + +namespace Casbin.Persist.Adapter.EFCore.UnitTest.Integration +{ + /// + /// Custom context classes for multi-schema testing + /// + public class TestCasbinDbContext1 : CasbinDbContext + { + public TestCasbinDbContext1( + DbContextOptions> options, + string schemaName, + string tableName) + : base(options, schemaName, tableName) + { + } + } + + public class TestCasbinDbContext2 : CasbinDbContext + { + public TestCasbinDbContext2( + DbContextOptions> options, + string schemaName, + string tableName) + : base(options, schemaName, tableName) + { + } + } + + public class TestCasbinDbContext3 : CasbinDbContext + { + public TestCasbinDbContext3( + DbContextOptions> options, + string schemaName, + string tableName) + : base(options, schemaName, tableName) + { + } + } + + /// + /// Integration tests for AutoSave behavior using PostgreSQL. + /// These tests verify that the adapter correctly handles AutoSave ON and OFF modes + /// when working with both regular policies and grouping policies. + /// + /// Note: These are integration tests (not unit tests) because they: + /// - Use the full Casbin Enforcer (not just the adapter in isolation) + /// - Test the interaction between Enforcer and Adapter + /// - Use real PostgreSQL database (not SQLite in-memory) + /// + [Trait("Category", "Integration")] + [Collection("IntegrationTests")] + public class AutoSaveTests : TestUtil, IAsyncLifetime + { + private readonly TransactionIntegrityTestFixture _fixture; + private readonly ITestOutputHelper _output; + private const string ModelPath = "examples/multi_context_model.conf"; + + public AutoSaveTests(TransactionIntegrityTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + } + + public async Task InitializeAsync() + { + // Clear all policies before each test + await _fixture.ClearAllPoliciesAsync(); + } + + public async Task DisposeAsync() + { + // Restore any tables that may have been dropped during test execution + await _fixture.RunMigrationsAsync(); + } + + /// + /// Tests regular policies with AutoSave ON (default behavior). + /// Verifies that operations immediately persist to the database. + /// + [Fact] + public async Task TestPolicyAutoSaveOn() + { + await using var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder>() + .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.PoliciesSchema)) + .Options; + + await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.PoliciesSchema); + await InitPolicyAsync(context); + + var adapter = new EFCoreAdapter(context); + var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); + var enforcer = new Enforcer(model, adapter); + + #region Load policy test + TestGetPolicy(enforcer, AsList( + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write"), + AsList("data2_admin", "data2", "read"), + AsList("data2_admin", "data2", "write") + )); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5); + #endregion + + #region Add policy test + await enforcer.AddPolicyAsync("alice", "data1", "write"); + TestGetPolicy(enforcer, AsList( + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write"), + AsList("data2_admin", "data2", "read"), + AsList("data2_admin", "data2", "write"), + AsList("alice", "data1", "write") + )); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 6); + #endregion + + #region Remove policy test + await enforcer.RemovePolicyAsync("alice", "data1", "write"); + TestGetPolicy(enforcer, AsList( + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write"), + AsList("data2_admin", "data2", "read"), + AsList("data2_admin", "data2", "write") + )); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5); + + await enforcer.RemoveFilteredPolicyAsync(0, "data2_admin"); + TestGetPolicy(enforcer, AsList( + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write") + )); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 3); + #endregion + + #region Update policy test + await enforcer.UpdatePolicyAsync(AsList("alice", "data1", "read"), + AsList("alice", "data2", "write")); + TestGetPolicy(enforcer, AsList( + AsList("alice", "data2", "write"), + AsList("bob", "data2", "write") + )); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 3); + + await enforcer.UpdatePolicyAsync(AsList("alice", "data2", "write"), + AsList("alice", "data1", "read")); + TestGetPolicy(enforcer, AsList( + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write") + )); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 3); + #endregion + + #region Batch APIs test + await enforcer.AddPoliciesAsync(new [] + { + new System.Collections.Generic.List{"alice", "data2", "write"}, + new System.Collections.Generic.List{"bob", "data1", "read"} + }); + TestGetPolicy(enforcer, AsList( + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write"), + AsList("alice", "data2", "write"), + AsList("bob", "data1", "read") + )); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5); + + await enforcer.RemovePoliciesAsync(new [] + { + new System.Collections.Generic.List{"alice", "data1", "read"}, + new System.Collections.Generic.List{"bob", "data2", "write"} + }); + TestGetPolicy(enforcer, AsList( + AsList("alice", "data2", "write"), + AsList("bob", "data1", "read") + )); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 3); + #endregion + } + + /// + /// Tests async version of regular policies with AutoSave ON. + /// + [Fact] + public async Task TestPolicyAutoSaveOnAsync() + { + await using var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder>() + .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.PoliciesSchema)) + .Options; + + await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.PoliciesSchema); + await InitPolicyAsync(context); + + var adapter = new EFCoreAdapter(context); + var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); + var enforcer = new Enforcer(model, adapter); + + #region Load policy test + TestGetPolicy(enforcer, AsList( + AsList("alice", "data1", "read"), + AsList("bob", "data2", "write"), + AsList("data2_admin", "data2", "read"), + AsList("data2_admin", "data2", "write") + )); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5); + #endregion + + #region Add policy test + await enforcer.AddPolicyAsync("alice", "data1", "write"); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 6); + #endregion + + #region Remove policy test + await enforcer.RemovePolicyAsync("alice", "data1", "write"); + Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5); + #endregion + } + + /// + /// Tests regular policies with AutoSave OFF. + /// Verifies that AddPolicy() correctly respects AutoSave OFF setting. + /// This documents the CORRECT behavior (contrast with grouping policy bug). + /// + [Fact] + public async Task TestPolicyAutoSaveOff() + { + await using var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder>() + .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.PoliciesSchema)) + .Options; + + await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.PoliciesSchema); + await InitPolicyAsync(context); + + var adapter = new EFCoreAdapter(context); + var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); + var enforcer = new Enforcer(model, adapter); + + // Disable AutoSave + enforcer.EnableAutoSave(false); + + // Verify initial state + Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); + + // Add policy - should NOT save to database with AutoSave OFF + enforcer.AddPolicy("charlie", "data3", "read"); + + // Verify policy was NOT saved yet (correct behavior for regular policies) + var countAfterAdd = await context.Policies.AsNoTracking().CountAsync(); + Assert.Equal(5, countAfterAdd); // Still 5 - CORRECT BEHAVIOR + + // Verify policy is NOT in database yet + var charlieBeforeSave = await context.Policies.AsNoTracking() + .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie"); + Assert.Null(charlieBeforeSave); // Not in database yet - CORRECT + + // When SavePolicy is called, it should save the policy + await enforcer.SavePolicyAsync(); + + // Now the policy should be in database + Assert.Equal(6, await context.Policies.AsNoTracking().CountAsync()); // 5 + 1 = 6 + + // Verify it's in database after SavePolicy + var charlieAfterSave = await context.Policies.AsNoTracking() + .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie"); + Assert.NotNull(charlieAfterSave); + } + + /// + /// Tests async version of regular policies with AutoSave OFF. + /// Verifies that AddPolicyAsync() correctly respects AutoSave OFF setting. + /// + [Fact] + public async Task TestPolicyAutoSaveOffAsync() + { + await using var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder>() + .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.PoliciesSchema)) + .Options; + + await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.PoliciesSchema); + await InitPolicyAsync(context); + + var adapter = new EFCoreAdapter(context); + var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); + var enforcer = new Enforcer(model, adapter); + + // Disable AutoSave + enforcer.EnableAutoSave(false); + + // Verify initial state + Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); + + // Add policy - should NOT save to database with AutoSave OFF + await enforcer.AddPolicyAsync("charlie", "data3", "read"); + + // Verify policy was NOT saved yet (correct behavior) + var countAfterAdd = await context.Policies.AsNoTracking().CountAsync(); + Assert.Equal(5, countAfterAdd); // Still 5 - CORRECT BEHAVIOR + + // When SavePolicy is called, it should save the policy + await enforcer.SavePolicyAsync(); + + // Now the policy should be in database + Assert.Equal(6, await context.Policies.AsNoTracking().CountAsync()); + + // Verify it's in database + var charlieAfterSave = await context.Policies.AsNoTracking() + .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie"); + Assert.NotNull(charlieAfterSave); + } + + /// + /// Tests grouping policies with AutoSave ON (default behavior). + /// This test verifies that AddGroupingPolicy() immediately saves to database. + /// + [Fact] + public async Task TestGroupingPolicyAutoSaveOn() + { + await using var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder>() + .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.GroupingsSchema)) + .Options; + + await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.GroupingsSchema); + await InitPolicyAsync(context); + + var adapter = new EFCoreAdapter(context); + var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); + var enforcer = new Enforcer(model, adapter); + + // Verify initial grouping policy + TestGetGroupingPolicy(enforcer, AsList( + AsList("alice", "data2_admin") + )); + Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); + + // Add grouping policy - should save immediately with AutoSave ON + await enforcer.AddGroupingPolicyAsync("bob", "data2_admin"); + + // Verify it's in Casbin's memory + TestGetGroupingPolicy(enforcer, AsList( + AsList("alice", "data2_admin"), + AsList("bob", "data2_admin") + )); + + // Verify it was saved to database immediately + Assert.Equal(6, await context.Policies.AsNoTracking().CountAsync()); + var bobGrouping = await context.Policies.AsNoTracking() + .FirstOrDefaultAsync(p => p.Type == "g" && p.Value1 == "bob" && p.Value2 == "data2_admin"); + Assert.NotNull(bobGrouping); + } + + /// + /// Tests grouping policies with AutoSave OFF. + /// + /// Verifies that AddGroupingPolicy() respects the EnableAutoSave(false) setting. + /// + /// Expected behavior (verified by this test): + /// - AddGroupingPolicy() should NOT save to database when AutoSave is OFF + /// - Only SavePolicy() should commit changes + /// + /// This test now passes with Casbin.NET 2.19.1+ which fixed the AutoSave bug. + /// + /// Related: Integration/README.md + /// + [Fact] + public async Task TestGroupingPolicyAutoSaveOff() + { + await using var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder>() + .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.GroupingsSchema)) + .Options; + + await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.GroupingsSchema); + await InitPolicyAsync(context); + + var adapter = new EFCoreAdapter(context); + var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); + var enforcer = new Enforcer(model, adapter); + + // Disable AutoSave + enforcer.EnableAutoSave(false); + + // Verify initial state + Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); + + // Add regular policy - should NOT save to database with AutoSave OFF + enforcer.AddPolicy("charlie", "data3", "read"); + + // Verify regular policy was NOT saved (correct behavior) + Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); + + // Add grouping policy - should NOT save to database with AutoSave OFF + await enforcer.AddGroupingPolicyAsync("bob", "data2_admin"); + + // TEST EXPECTATION: Grouping policy should NOT be saved yet (AutoSave is OFF) + // BUG: This will fail because Casbin.NET incorrectly saves it (Actual: 6, Expected: 5) + var savedCountAfterAdd = await context.Policies.AsNoTracking().CountAsync(); + Assert.Equal(5, savedCountAfterAdd); // FAILS due to bug: actual is 6 + + // Verify the grouping policy is NOT in database yet + var bobGroupingBeforeSave = await context.Policies.AsNoTracking() + .FirstOrDefaultAsync(p => p.Type == "g" && p.Value1 == "bob" && p.Value2 == "data2_admin"); + Assert.Null(bobGroupingBeforeSave); // FAILS due to bug: it exists + + // When SavePolicy is called, it should save BOTH policies + await enforcer.SavePolicyAsync(); + + // Now both policies should be in database + Assert.Equal(7, await context.Policies.AsNoTracking().CountAsync()); // 5 original + 1 charlie + 1 bob = 7 + + // Verify both are in database after SavePolicy + var charliePolicy = await context.Policies.AsNoTracking() + .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie"); + var bobGroupingAfterSave = await context.Policies.AsNoTracking() + .FirstOrDefaultAsync(p => p.Type == "g" && p.Value1 == "bob" && p.Value2 == "data2_admin"); + + Assert.NotNull(charliePolicy); + Assert.NotNull(bobGroupingAfterSave); + } + + /// + /// Tests async version of grouping policies with AutoSave OFF. + /// + /// Verifies that AddGroupingPolicyAsync() respects the EnableAutoSave(false) setting. + /// + /// Expected behavior (verified by this test): AddGroupingPolicyAsync() should NOT save when AutoSave is OFF. + /// + /// This test now passes with Casbin.NET 2.19.1+ which fixed the AutoSave bug. + /// + [Fact] + public async Task TestGroupingPolicyAutoSaveOffAsync() + { + await using var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder>() + .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.GroupingsSchema)) + .Options; + + await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.GroupingsSchema); + await InitPolicyAsync(context); + + var adapter = new EFCoreAdapter(context); + var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); + var enforcer = new Enforcer(model, adapter); + + // Disable AutoSave + enforcer.EnableAutoSave(false); + + // Verify initial state + Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); + + // Add regular policy - should NOT save to database with AutoSave OFF + await enforcer.AddPolicyAsync("charlie", "data3", "read"); + + // Verify regular policy was NOT saved (correct behavior) + Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); + + // Add grouping policy - should NOT save to database with AutoSave OFF + await enforcer.AddGroupingPolicyAsync("bob", "data2_admin"); + + // TEST EXPECTATION: Grouping policy should NOT be saved yet + // BUG: This will fail because Casbin.NET incorrectly saves it (Actual: 6, Expected: 5) + var savedCountAfterAdd = await context.Policies.AsNoTracking().CountAsync(); + Assert.Equal(5, savedCountAfterAdd); // FAILS due to bug: actual is 6 + + // When SavePolicy is called, it should save BOTH policies + await enforcer.SavePolicyAsync(); + + // Now both policies should be in database + Assert.Equal(7, await context.Policies.AsNoTracking().CountAsync()); // 5 original + 1 charlie + 1 bob = 7 + + // Verify both are in database + var charliePolicy = await context.Policies.AsNoTracking() + .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie"); + var bobGrouping = await context.Policies.AsNoTracking() + .FirstOrDefaultAsync(p => p.Type == "g" && p.Value1 == "bob" && p.Value2 == "data2_admin"); + + Assert.NotNull(charliePolicy); + Assert.NotNull(bobGrouping); + } + + #region Multi-Context AutoSave Tests + + /// + /// Provider that routes policy types to three separate contexts + /// + private class ThreeWayContextProvider : ICasbinDbContextProvider + { + private readonly CasbinDbContext _policyContext; + private readonly CasbinDbContext _groupingContext; + private readonly CasbinDbContext _roleContext; + private readonly System.Data.Common.DbConnection? _sharedConnection; + + public ThreeWayContextProvider( + CasbinDbContext policyContext, + CasbinDbContext groupingContext, + CasbinDbContext roleContext, + System.Data.Common.DbConnection? sharedConnection) + { + _policyContext = policyContext; + _groupingContext = groupingContext; + _roleContext = roleContext; + _sharedConnection = sharedConnection; + } + + public DbContext GetContextForPolicyType(string policyType) + { + return policyType switch + { + "p" => _policyContext, // p policies → casbin_policies schema + "g" => _groupingContext, // g groupings → casbin_groupings schema + "g2" => _roleContext, // g2 roles → casbin_roles schema + _ => _policyContext + }; + } + + public IEnumerable GetAllContexts() + { + return new[] { _policyContext, _groupingContext, _roleContext }; + } + + public System.Data.Common.DbConnection? GetSharedConnection() + { + return _sharedConnection; + } + } + + /// + /// Tests AutoSave OFF with multiple contexts and rollback on failure. + /// + /// Verifies that: + /// - With AutoSave OFF, policies batch in memory (not commit) + /// - SavePolicy() uses shared transaction and rolls back atomically on failure + /// + /// This test now passes with Casbin.NET 2.19.1+ which fixed the AutoSave bug. + /// + [Fact] + public async Task TestAutoSaveOff_MultiContext_RollbackOnFailure() + { + _output.WriteLine("=== AUTOSAVE OFF - MULTI-CONTEXT ATOMIC ROLLBACK TEST ==="); + _output.WriteLine("Goal: With AutoSave OFF, SavePolicy should use shared transaction and rollback atomically"); + _output.WriteLine(""); + + // Clear all data first + await _fixture.ClearAllPoliciesAsync(); + + // Create ONE shared connection + var sharedConnection = new NpgsqlConnection(_fixture.ConnectionString); + await sharedConnection.OpenAsync(); + + try + { + _output.WriteLine($"Shared connection: {sharedConnection.GetHashCode()}"); + _output.WriteLine(""); + + // Create three contexts using the SAME connection object + var options1 = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) + .Options; + var policyContext = new TestCasbinDbContext1(options1, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule"); + + var options2 = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) + .Options; + var groupingContext = new TestCasbinDbContext2(options2, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule"); + + var options3 = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) + .Options; + var roleContext = new TestCasbinDbContext3(options3, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule"); + + // Create provider and adapter + var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, sharedConnection); + var adapter = new EFCoreAdapter(provider); + + // Create enforcer and DISABLE AutoSave + var model = DefaultModel.CreateFromFile(ModelPath); + var enforcer = new Enforcer(model); + enforcer.SetAdapter(adapter); + enforcer.EnableAutoSave(false); // ← CRITICAL: Disable AutoSave + _output.WriteLine("AutoSave disabled"); + _output.WriteLine(""); + + // Add multiple policies to each type + _output.WriteLine("Adding policies with AutoSave OFF (should batch in memory):"); + enforcer.AddPolicy("alice", "data1", "read"); + enforcer.AddPolicy("bob", "data2", "write"); + _output.WriteLine(" Added 2 p policies"); + + enforcer.AddGroupingPolicy("alice", "admin"); + enforcer.AddGroupingPolicy("bob", "user"); + _output.WriteLine(" Added 2 g groupings"); + + enforcer.AddNamedGroupingPolicy("g2", "admin", "role-superuser"); + enforcer.AddNamedGroupingPolicy("g2", "user", "role-basic"); + _output.WriteLine(" Added 2 g2 roles"); + _output.WriteLine(""); + + // Check database state - should be EMPTY (policies batched, not committed) + var beforePoliciesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); + var beforeGroupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); + var beforeRolesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema); + _output.WriteLine($"STATE BEFORE DROP (should be 0,0,0): ({beforePoliciesCount}, {beforeGroupingsCount}, {beforeRolesCount})"); + + if (beforePoliciesCount == 0 && beforeGroupingsCount == 0 && beforeRolesCount == 0) + { + _output.WriteLine("✓ Confirmed: AutoSave OFF prevents immediate commits"); + } + else + { + _output.WriteLine("✗ WARNING: Policies were committed despite AutoSave OFF!"); + } + _output.WriteLine(""); + + // NOW: Drop the table for the third schema to force a failure + _output.WriteLine("FORCING FAILURE: Dropping casbin_roles.casbin_rule table..."); + await using (var cmd = sharedConnection.CreateCommand()) + { + cmd.CommandText = $"DROP TABLE {TransactionIntegrityTestFixture.RolesSchema}.casbin_rule"; + await cmd.ExecuteNonQueryAsync(); + } + _output.WriteLine("Table dropped!"); + _output.WriteLine(""); + + // Try to save - this SHOULD fail + _output.WriteLine("Calling SavePolicyAsync()... (expecting exception)"); + var exception = await Assert.ThrowsAnyAsync(async () => + { + await enforcer.SavePolicyAsync(); + }); + _output.WriteLine($"✓ Exception caught as expected: {exception.GetType().Name}"); + _output.WriteLine($" Message: {exception.Message}"); + _output.WriteLine(""); + + // Count policies in the first two schemas (third schema table is gone) + var policiesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); + + _output.WriteLine("RESULTS - Policies per schema after failure:"); + _output.WriteLine($" casbin_policies: {policiesCount}"); + _output.WriteLine($" casbin_groupings: {groupingsCount}"); + _output.WriteLine($" casbin_roles: N/A (table dropped)"); + _output.WriteLine(""); + + // ASSERT: With AutoSave OFF and shared connection, SavePolicy should roll back ALL changes + if (policiesCount == 0 && groupingsCount == 0) + { + _output.WriteLine("✓✓✓ AUTOSAVE OFF ATOMIC TRANSACTION TEST PASSED!"); + _output.WriteLine("SavePolicy used shared transaction and rolled back atomically"); + } + else + { + _output.WriteLine("✗✗✗ AUTOSAVE OFF ATOMIC TRANSACTION TEST FAILED!"); + _output.WriteLine($"Expected: (0, 0), Got: ({policiesCount}, {groupingsCount})"); + } + + Assert.Equal(0, policiesCount); + Assert.Equal(0, groupingsCount); + + await policyContext.DisposeAsync(); + await groupingContext.DisposeAsync(); + await roleContext.DisposeAsync(); + } + finally + { + await sharedConnection.DisposeAsync(); + } + } + + /// + /// Tests AutoSave ON with multiple contexts showing individual commits. + /// Verifies that each AddPolicy commits independently (no cross-context atomicity). + /// + [Fact] + public async Task TestAutoSaveOn_MultiContext_IndividualCommits() + { + _output.WriteLine("=== AUTOSAVE ON - INDIVIDUAL COMMITS TEST ==="); + _output.WriteLine("Goal: With AutoSave ON, each Add should commit independently (no atomicity)"); + _output.WriteLine(""); + + // Clear all data first + await _fixture.ClearAllPoliciesAsync(); + + // Create ONE shared connection + var sharedConnection = new NpgsqlConnection(_fixture.ConnectionString); + await sharedConnection.OpenAsync(); + + try + { + // Create three contexts using the SAME connection object + var options1 = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) + .Options; + var policyContext = new TestCasbinDbContext1(options1, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule"); + + var options2 = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) + .Options; + var groupingContext = new TestCasbinDbContext2(options2, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule"); + + var options3 = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) + .Options; + var roleContext = new TestCasbinDbContext3(options3, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule"); + + // Create provider and adapter + var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, sharedConnection); + var adapter = new EFCoreAdapter(provider); + + // Create enforcer with AutoSave ON (default) + var model = DefaultModel.CreateFromFile(ModelPath); + var enforcer = new Enforcer(model); + enforcer.SetAdapter(adapter); + // enforcer.EnableAutoSave(true); // Default is true, no need to set + _output.WriteLine("AutoSave enabled (default)"); + _output.WriteLine(""); + + // Add policies to context 1 and check DB immediately + _output.WriteLine("Step 1: Adding 2 p policies (should commit immediately):"); + enforcer.AddPolicy("alice", "data1", "read"); + enforcer.AddPolicy("bob", "data2", "write"); + var step1Count = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); + _output.WriteLine($" DB state after p policies: ({step1Count}, ?, ?)"); + Assert.Equal(2, step1Count); + _output.WriteLine(""); + + // Add policies to context 2 and check DB immediately + _output.WriteLine("Step 2: Adding 2 g policies (should commit immediately):"); + enforcer.AddGroupingPolicy("alice", "admin"); + enforcer.AddGroupingPolicy("bob", "user"); + var step2CountPolicy = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); + var step2CountGrouping = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); + _output.WriteLine($" DB state after g policies: ({step2CountPolicy}, {step2CountGrouping}, ?)"); + Assert.Equal(2, step2CountPolicy); + Assert.Equal(2, step2CountGrouping); + _output.WriteLine(""); + + // NOW: Drop the table for the third schema + _output.WriteLine("Step 3: Dropping casbin_roles.casbin_rule table..."); + await using (var cmd = sharedConnection.CreateCommand()) + { + cmd.CommandText = $"DROP TABLE {TransactionIntegrityTestFixture.RolesSchema}.casbin_rule"; + await cmd.ExecuteNonQueryAsync(); + } + _output.WriteLine("Table dropped!"); + _output.WriteLine(""); + + // Try to add policy to context 3 - should fail + _output.WriteLine("Step 4: Trying to add g2 policy (expecting exception):"); + var exception = await Assert.ThrowsAnyAsync(async () => + { + await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "role-superuser"); + }); + _output.WriteLine($"✓ Exception caught as expected: {exception.GetType().Name}"); + _output.WriteLine($" Message: {exception.Message}"); + _output.WriteLine(""); + + // Check final state - contexts 1 & 2 should still have their data + var finalPoliciesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); + var finalGroupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); + + _output.WriteLine("FINAL RESULTS:"); + _output.WriteLine($" casbin_policies: {finalPoliciesCount}"); + _output.WriteLine($" casbin_groupings: {finalGroupingsCount}"); + _output.WriteLine($" casbin_roles: N/A (table dropped)"); + _output.WriteLine(""); + + // ASSERT: With AutoSave ON, each context committed independently + if (finalPoliciesCount == 2 && finalGroupingsCount == 2) + { + _output.WriteLine("✓✓✓ AUTOSAVE ON INDIVIDUAL COMMITS TEST PASSED!"); + _output.WriteLine("Each AddPolicy committed independently, no cross-context atomicity"); + } + else + { + _output.WriteLine("✗✗✗ AUTOSAVE ON INDIVIDUAL COMMITS TEST FAILED!"); + _output.WriteLine($"Expected: (2, 2), Got: ({finalPoliciesCount}, {finalGroupingsCount})"); + } + + Assert.Equal(2, finalPoliciesCount); + Assert.Equal(2, finalGroupingsCount); + + await policyContext.DisposeAsync(); + await groupingContext.DisposeAsync(); + await roleContext.DisposeAsync(); + } + finally + { + await sharedConnection.DisposeAsync(); + } + } + + /// + /// Tests AutoSave OFF success path with batched commit across multiple contexts. + /// + /// Verifies that with AutoSave OFF, SavePolicy() batches all operations + /// in a shared transaction and commits atomically. + /// + /// This test now passes with Casbin.NET 2.19.1+ which fixed the AutoSave bug. + /// + [Fact] + public async Task TestAutoSaveOff_MultiContext_BatchedCommit() + { + _output.WriteLine("=== AUTOSAVE OFF - SUCCESS PATH TEST ==="); + _output.WriteLine("Goal: With AutoSave OFF, SavePolicy should batch all operations in shared transaction"); + _output.WriteLine(""); + + // Clear all data first + await _fixture.ClearAllPoliciesAsync(); + + // Create ONE shared connection + var sharedConnection = new NpgsqlConnection(_fixture.ConnectionString); + await sharedConnection.OpenAsync(); + + try + { + // Create three contexts + var options1 = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) + .Options; + var policyContext = new TestCasbinDbContext1(options1, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule"); + + var options2 = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) + .Options; + var groupingContext = new TestCasbinDbContext2(options2, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule"); + + var options3 = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) + .Options; + var roleContext = new TestCasbinDbContext3(options3, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule"); + + var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, sharedConnection); + var adapter = new EFCoreAdapter(provider); + + var model = DefaultModel.CreateFromFile(ModelPath); + var enforcer = new Enforcer(model); + enforcer.SetAdapter(adapter); + enforcer.EnableAutoSave(false); + _output.WriteLine("AutoSave disabled"); + _output.WriteLine(""); + + // Add policies + _output.WriteLine("Adding policies with AutoSave OFF:"); + enforcer.AddPolicy("alice", "data1", "read"); + enforcer.AddPolicy("bob", "data2", "write"); + enforcer.AddGroupingPolicy("alice", "admin"); + enforcer.AddGroupingPolicy("bob", "user"); + enforcer.AddNamedGroupingPolicy("g2", "admin", "role-superuser"); + enforcer.AddNamedGroupingPolicy("g2", "user", "role-basic"); + _output.WriteLine(" Added 6 policies total"); + _output.WriteLine(""); + + // Check DB before SavePolicy + var beforeCount1 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); + var beforeCount2 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); + var beforeCount3 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema); + _output.WriteLine($"DB state BEFORE SavePolicy: ({beforeCount1}, {beforeCount2}, {beforeCount3})"); + + if (beforeCount1 == 0 && beforeCount2 == 0 && beforeCount3 == 0) + { + _output.WriteLine("✓ Confirmed: Policies batched in memory, not committed yet"); + } + _output.WriteLine(""); + + // Call SavePolicy + _output.WriteLine("Calling SavePolicyAsync()..."); + await enforcer.SavePolicyAsync(); + _output.WriteLine("SavePolicyAsync() completed"); + _output.WriteLine(""); + + // Check final state + var finalCount1 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); + var finalCount2 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); + var finalCount3 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema); + + _output.WriteLine($"DB state AFTER SavePolicy: ({finalCount1}, {finalCount2}, {finalCount3})"); + _output.WriteLine(""); + + if (finalCount1 == 2 && finalCount2 == 2 && finalCount3 == 2) + { + _output.WriteLine("✓✓✓ AUTOSAVE OFF SUCCESS PATH TEST PASSED!"); + _output.WriteLine("SavePolicy committed all batched policies atomically"); + } + else + { + _output.WriteLine("✗✗✗ AUTOSAVE OFF SUCCESS PATH TEST FAILED!"); + _output.WriteLine($"Expected: (2, 2, 2), Got: ({finalCount1}, {finalCount2}, {finalCount3})"); + } + + Assert.Equal(2, finalCount1); + Assert.Equal(2, finalCount2); + Assert.Equal(2, finalCount3); + + await policyContext.DisposeAsync(); + await groupingContext.DisposeAsync(); + await roleContext.DisposeAsync(); + } + finally + { + await sharedConnection.DisposeAsync(); + } + } + + #endregion + + private static async Task InitPolicyAsync(CasbinDbContext context) + { + // Clear existing policies - use AsNoTracking to avoid concurrency exceptions + // when policies may have been deleted by fixture cleanup + var existing = await context.Policies.AsNoTracking().ToListAsync(); + if (existing.Any()) + { + context.Policies.AttachRange(existing); + context.Policies.RemoveRange(existing); + await context.SaveChangesAsync(); + } + + // Add test data + context.Policies.Add(new EFCorePersistPolicy() + { + Type = "p", + Value1 = "alice", + Value2 = "data1", + Value3 = "read", + }); + context.Policies.Add(new EFCorePersistPolicy() + { + Type = "p", + Value1 = "bob", + Value2 = "data2", + Value3 = "write", + }); + context.Policies.Add(new EFCorePersistPolicy() + { + Type = "p", + Value1 = "data2_admin", + Value2 = "data2", + Value3 = "read", + }); + context.Policies.Add(new EFCorePersistPolicy() + { + Type = "p", + Value1 = "data2_admin", + Value2 = "data2", + Value3 = "write", + }); + context.Policies.Add(new EFCorePersistPolicy() + { + Type = "g", + Value1 = "alice", + Value2 = "data2_admin", + }); + await context.SaveChangesAsync(); + } + } +} diff --git a/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/SchemaDistributionTests.cs b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/SchemaDistributionTests.cs new file mode 100644 index 0000000..b555ca7 --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/SchemaDistributionTests.cs @@ -0,0 +1,342 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Casbin; +using Casbin.Model; +using Npgsql; +using Microsoft.EntityFrameworkCore; +using Xunit; +using Xunit.Abstractions; + +#nullable enable + +namespace Casbin.Persist.Adapter.EFCore.UnitTest.Integration +{ + /// + /// Tests to verify whether HasDefaultSchema() properly distributes policies across PostgreSQL schemas, + /// both with separate connections and with shared connections. + /// + /// Purpose: Determine if explicit SET search_path is necessary or if EF Core's HasDefaultSchema() + /// generates schema-qualified SQL that works correctly with shared connections. + /// + [Trait("Category", "Integration")] + [Collection("IntegrationTests")] + public class SchemaDistributionTests : IClassFixture, IAsyncLifetime + { + private readonly TransactionIntegrityTestFixture _fixture; + private readonly ITestOutputHelper _output; + private const string ModelPath = "examples/multi_context_model.conf"; + + public SchemaDistributionTests(TransactionIntegrityTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + } + + public Task InitializeAsync() => _fixture.ClearAllPoliciesAsync(); + public Task DisposeAsync() => _fixture.RunMigrationsAsync(); + + #region Helper: Derived Context Classes + + /// + /// Derived context for policies schema + /// + public class TestCasbinDbContext1 : CasbinDbContext + { + public TestCasbinDbContext1( + DbContextOptions> options, + string schemaName, + string tableName) + : base(options, schemaName, tableName) + { + } + } + + /// + /// Derived context for groupings schema + /// + public class TestCasbinDbContext2 : CasbinDbContext + { + public TestCasbinDbContext2( + DbContextOptions> options, + string schemaName, + string tableName) + : base(options, schemaName, tableName) + { + } + } + + /// + /// Derived context for roles schema + /// + public class TestCasbinDbContext3 : CasbinDbContext + { + public TestCasbinDbContext3( + DbContextOptions> options, + string schemaName, + string tableName) + : base(options, schemaName, tableName) + { + } + } + + #endregion + + #region Helper: Three-Context Provider + + /// + /// Provider that routes policy types to three separate contexts + /// + private class ThreeWayContextProvider : ICasbinDbContextProvider + { + private readonly CasbinDbContext _policyContext; + private readonly CasbinDbContext _groupingContext; + private readonly CasbinDbContext _roleContext; + private readonly System.Data.Common.DbConnection? _sharedConnection; + + public ThreeWayContextProvider( + CasbinDbContext policyContext, + CasbinDbContext groupingContext, + CasbinDbContext roleContext, + System.Data.Common.DbConnection? sharedConnection) + { + _policyContext = policyContext; + _groupingContext = groupingContext; + _roleContext = roleContext; + _sharedConnection = sharedConnection; + } + + public DbContext GetContextForPolicyType(string policyType) + { + return policyType switch + { + "p" => _policyContext, // p policies → casbin_policies schema + "g" => _groupingContext, // g groupings → casbin_groupings schema + "g2" => _roleContext, // g2 roles → casbin_roles schema + _ => _policyContext + }; + } + + public IEnumerable GetAllContexts() + { + return new[] { _policyContext, _groupingContext, _roleContext }; + } + + public System.Data.Common.DbConnection? GetSharedConnection() + { + return _sharedConnection; + } + } + + #endregion + + #region Test 1: Separate Connections (Control/Baseline) + + /// + /// BASELINE TEST: Proves that HasDefaultSchema() correctly distributes policies across schemas + /// when contexts use SEPARATE connections (no shared connection). + /// + /// This is the baseline that should work regardless of any SET search_path logic. + /// + [Fact] + public async Task SavePolicy_SeparateConnections_ShouldDistributeAcrossSchemas() + { + _output.WriteLine("=== TEST: Separate Connections - Schema Distribution ==="); + + // Create three contexts with SEPARATE connection strings (no shared connection) + var policyOptions = new DbContextOptionsBuilder>() + .UseNpgsql(_fixture.ConnectionString) // Connection #1 + .Options; + var policyContext = new TestCasbinDbContext1(policyOptions, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule"); + + var groupingOptions = new DbContextOptionsBuilder>() + .UseNpgsql(_fixture.ConnectionString) // Connection #2 + .Options; + var groupingContext = new TestCasbinDbContext2(groupingOptions, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule"); + + var roleOptions = new DbContextOptionsBuilder>() + .UseNpgsql(_fixture.ConnectionString) // Connection #3 + .Options; + var roleContext = new TestCasbinDbContext3(roleOptions, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule"); + + _output.WriteLine("Created three contexts with SEPARATE connections"); + + // Verify they are different connection objects + var conn1 = policyContext.Database.GetDbConnection(); + var conn2 = groupingContext.Database.GetDbConnection(); + var conn3 = roleContext.Database.GetDbConnection(); + + Assert.False(ReferenceEquals(conn1, conn2), "Connections 1 and 2 should be different objects"); + Assert.False(ReferenceEquals(conn2, conn3), "Connections 2 and 3 should be different objects"); + _output.WriteLine("Verified: Contexts use DIFFERENT DbConnection objects"); + + try + { + // Create provider and adapter + // Pass null since these contexts use separate connections + var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, null); + var adapter = new EFCoreAdapter(provider); + + // Create enforcer without loading policy (tables might be empty) + var model = DefaultModel.CreateFromFile(ModelPath); + var enforcer = new Enforcer(model); + enforcer.SetAdapter(adapter); + + // Add policies to in-memory model (not persisted yet) + enforcer.AddPolicy("alice", "data1", "read"); // → casbin_policies + enforcer.AddGroupingPolicy("alice", "admin"); // → casbin_groupings + enforcer.AddNamedGroupingPolicy("g2", "admin", "role-superuser"); // → casbin_roles + + _output.WriteLine("Added policies to in-memory model:"); + _output.WriteLine(" p policy → casbin_policies"); + _output.WriteLine(" g policy → casbin_groupings"); + _output.WriteLine(" g2 policy → casbin_roles"); + + // Save to database + await enforcer.SavePolicyAsync(); + _output.WriteLine("Called SavePolicyAsync()"); + + // Verify distribution across schemas + var policiesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); + var rolesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema); + + _output.WriteLine($"Schema distribution:"); + _output.WriteLine($" casbin_policies: {policiesCount} policy"); + _output.WriteLine($" casbin_groupings: {groupingsCount} policy"); + _output.WriteLine($" casbin_roles: {rolesCount} policy"); + + // CRITICAL ASSERTION: Policies should be distributed across all three schemas + Assert.Equal(1, policiesCount); + Assert.Equal(1, groupingsCount); + Assert.Equal(1, rolesCount); + + _output.WriteLine("✓ BASELINE TEST PASSED: HasDefaultSchema() distributes policies correctly with separate connections"); + } + finally + { + await policyContext.DisposeAsync(); + await groupingContext.DisposeAsync(); + await roleContext.DisposeAsync(); + } + } + + #endregion + + #region Test 2: Shared Connection (Critical Test) + + /// + /// CRITICAL TEST: Determines if HasDefaultSchema() correctly distributes policies across schemas + /// when contexts share a SINGLE DbConnection object. + /// + /// If this test FAILS: SET search_path approach is necessary + /// If this test PASSES: SET search_path approach is NOT necessary + /// + [Fact] + public async Task SavePolicy_SharedConnection_ShouldDistributeAcrossSchemas() + { + _output.WriteLine("=== TEST: Shared Connection - Schema Distribution ==="); + + // Create ONE shared connection (CRITICAL for this test) + var sharedConnection = new NpgsqlConnection(_fixture.ConnectionString); + await sharedConnection.OpenAsync(); + _output.WriteLine("Opened shared connection for all three contexts"); + + try + { + // Create three contexts using SAME connection object + var policyOptions = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) // ✅ Shared connection + .Options; + var policyContext = new TestCasbinDbContext1(policyOptions, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule"); + + var groupingOptions = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) // ✅ Same connection + .Options; + var groupingContext = new TestCasbinDbContext2(groupingOptions, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule"); + + var roleOptions = new DbContextOptionsBuilder>() + .UseNpgsql(sharedConnection) // ✅ Same connection + .Options; + var roleContext = new TestCasbinDbContext3(roleOptions, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule"); + + _output.WriteLine("Created three contexts sharing the same connection"); + + // Verify reference equality + var conn1 = policyContext.Database.GetDbConnection(); + var conn2 = groupingContext.Database.GetDbConnection(); + var conn3 = roleContext.Database.GetDbConnection(); + + Assert.True(ReferenceEquals(conn1, conn2), "Connections 1 and 2 should be the SAME object"); + Assert.True(ReferenceEquals(conn2, conn3), "Connections 2 and 3 should be the SAME object"); + _output.WriteLine("Verified: All contexts share the SAME DbConnection object (reference equality)"); + + // Create provider and adapter + // Pass sharedConnection since all contexts share it + var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, sharedConnection); + var adapter = new EFCoreAdapter(provider); + + // Create enforcer without loading policy (tables might be empty) + var model = DefaultModel.CreateFromFile(ModelPath); + var enforcer = new Enforcer(model); + enforcer.SetAdapter(adapter); + + // Add policies to in-memory model (not persisted yet) + enforcer.AddPolicy("bob", "data2", "write"); // → casbin_policies + enforcer.AddGroupingPolicy("bob", "developer"); // → casbin_groupings + enforcer.AddNamedGroupingPolicy("g2", "developer", "role-contributor"); // → casbin_roles + + _output.WriteLine("Added policies to in-memory model:"); + _output.WriteLine(" p policy → should go to casbin_policies"); + _output.WriteLine(" g policy → should go to casbin_groupings"); + _output.WriteLine(" g2 policy → should go to casbin_roles"); + + // Save to database + await enforcer.SavePolicyAsync(); + _output.WriteLine("Called SavePolicyAsync()"); + + // Verify distribution across schemas + var policiesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); + var rolesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema); + + _output.WriteLine($"Schema distribution:"); + _output.WriteLine($" casbin_policies: {policiesCount} policy"); + _output.WriteLine($" casbin_groupings: {groupingsCount} policy"); + _output.WriteLine($" casbin_roles: {rolesCount} policy"); + + // CRITICAL ASSERTION: Policies should be distributed across all three schemas + // If all policies end up in ONE schema, HasDefaultSchema() does NOT work with shared connections + // and we NEED the SET search_path approach + + if (policiesCount == 1 && groupingsCount == 1 && rolesCount == 1) + { + _output.WriteLine("✓✓✓ SHARED CONNECTION TEST PASSED!"); + _output.WriteLine("HasDefaultSchema() correctly distributes policies even with shared connection"); + _output.WriteLine("CONCLUSION: SET search_path approach is NOT necessary"); + } + else + { + _output.WriteLine("✗✗✗ SHARED CONNECTION TEST FAILED!"); + _output.WriteLine($"Expected distribution: (1, 1, 1), Got: ({policiesCount}, {groupingsCount}, {rolesCount})"); + _output.WriteLine("CONCLUSION: SET search_path approach IS necessary for shared connections"); + } + + Assert.Equal(1, policiesCount); + Assert.Equal(1, groupingsCount); + Assert.Equal(1, rolesCount); + + await policyContext.DisposeAsync(); + await groupingContext.DisposeAsync(); + await roleContext.DisposeAsync(); + } + finally + { + await sharedConnection.DisposeAsync(); + } + } + + #endregion + } +} diff --git a/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTests.cs b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTests.cs new file mode 100644 index 0000000..b19349f --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTests.cs @@ -0,0 +1,578 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Casbin; +using Casbin.Persist.Adapter.EFCore.Entities; +using Npgsql; +using Microsoft.EntityFrameworkCore; +using Xunit; + +#nullable enable + +namespace Casbin.Persist.Adapter.EFCore.UnitTest.Integration +{ + /// + /// Integration tests verifying transaction integrity guarantees for multi-context scenarios. + /// These tests prove that shared DbConnection objects enable atomic transactions across multiple contexts. + /// + /// IMPORTANT: These tests are excluded from CI/CD via the "Integration" trait. + /// Run locally with: dotnet test --filter "Category=Integration" + /// + [Trait("Category", "Integration")] + [Collection("IntegrationTests")] + public class TransactionIntegrityTests : IClassFixture, IAsyncLifetime + { + private readonly TransactionIntegrityTestFixture _fixture; + private const string ModelPath = "examples/multi_context_model.conf"; + + public TransactionIntegrityTests(TransactionIntegrityTestFixture fixture) + { + _fixture = fixture; + } + + public Task InitializeAsync() => _fixture.ClearAllPoliciesAsync(); + + public Task DisposeAsync() => _fixture.RunMigrationsAsync(); + + #region Helper: Derived Context Classes + + /// + /// Derived context for policies schema + /// + public class TestCasbinDbContext1 : CasbinDbContext + { + public TestCasbinDbContext1( + DbContextOptions> options, + string schemaName, + string tableName) + : base(options, schemaName, tableName) + { + } + } + + /// + /// Derived context for groupings schema + /// + public class TestCasbinDbContext2 : CasbinDbContext + { + public TestCasbinDbContext2( + DbContextOptions> options, + string schemaName, + string tableName) + : base(options, schemaName, tableName) + { + } + } + + /// + /// Derived context for roles schema + /// + public class TestCasbinDbContext3 : CasbinDbContext + { + public TestCasbinDbContext3( + DbContextOptions> options, + string schemaName, + string tableName) + : base(options, schemaName, tableName) + { + } + } + + #endregion + + #region Helper Methods + + /// + /// Creates a three-way context provider routing policies to different schemas: + /// - p, p2, p3... → policies schema + /// - g → groupings schema + /// - g2, g3, g4... → roles schema + /// + private class ThreeWayPolicyTypeProvider : ICasbinDbContextProvider + { + private readonly DbContext _policyContext; + private readonly DbContext _groupingContext; + private readonly DbContext _roleContext; + private readonly System.Data.Common.DbConnection? _sharedConnection; + + public ThreeWayPolicyTypeProvider( + DbContext policyContext, + DbContext groupingContext, + DbContext roleContext, + System.Data.Common.DbConnection? sharedConnection) + { + _policyContext = policyContext ?? throw new ArgumentNullException(nameof(policyContext)); + _groupingContext = groupingContext ?? throw new ArgumentNullException(nameof(groupingContext)); + _roleContext = roleContext ?? throw new ArgumentNullException(nameof(roleContext)); + _sharedConnection = sharedConnection; + } + + public DbContext GetContextForPolicyType(string policyType) + { + if (string.IsNullOrEmpty(policyType)) + return _policyContext; + + // Route p policies to policy context + if (policyType.StartsWith("p", StringComparison.OrdinalIgnoreCase)) + return _policyContext; + + // Route g2+ to role context + if (policyType.StartsWith("g2", StringComparison.OrdinalIgnoreCase) || + policyType.StartsWith("g3", StringComparison.OrdinalIgnoreCase) || + policyType.StartsWith("g4", StringComparison.OrdinalIgnoreCase)) + return _roleContext; + + // Route g to grouping context + return _groupingContext; + } + + public IEnumerable GetAllContexts() + { + return new[] { _policyContext, _groupingContext, _roleContext }; + } + + public System.Data.Common.DbConnection? GetSharedConnection() + { + return _sharedConnection; + } + } + + /// + /// Creates an enforcer with three contexts sharing the same DbConnection + /// + private async Task<(Enforcer enforcer, NpgsqlConnection connection)> CreateEnforcerWithSharedConnectionAsync() + { + // Create ONE shared connection + var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + + // Create three contexts sharing the same connection + var policyContext = CreateContext(connection, TransactionIntegrityTestFixture.PoliciesSchema); + var groupingContext = CreateContext(connection, TransactionIntegrityTestFixture.GroupingsSchema); + var roleContext = CreateContext(connection, TransactionIntegrityTestFixture.RolesSchema); + + // Create provider routing policy types to appropriate contexts + var provider = new ThreeWayPolicyTypeProvider(policyContext, groupingContext, roleContext, connection); + + // Create adapter and enforcer + var adapter = new EFCoreAdapter(provider); + var enforcer = new Enforcer(ModelPath, adapter); + + await enforcer.LoadPolicyAsync(); + + return (enforcer, connection); + } + + /// + /// Creates an enforcer with three contexts using SEPARATE DbConnections (same connection string) + /// This is used to demonstrate non-atomic behavior when connections are not shared. + /// + private async Task CreateEnforcerWithSeparateConnectionsAsync() + { + // Create THREE separate connections with same connection string + var policyConnection = new NpgsqlConnection(_fixture.ConnectionString); + var groupingConnection = new NpgsqlConnection(_fixture.ConnectionString); + var roleConnection = new NpgsqlConnection(_fixture.ConnectionString); + + await policyConnection.OpenAsync(); + await groupingConnection.OpenAsync(); + await roleConnection.OpenAsync(); + + // Create three contexts with different connection objects + var policyContext = CreateContext(policyConnection, TransactionIntegrityTestFixture.PoliciesSchema); + var groupingContext = CreateContext(groupingConnection, TransactionIntegrityTestFixture.GroupingsSchema); + var roleContext = CreateContext(roleConnection, TransactionIntegrityTestFixture.RolesSchema); + + // Create provider routing policy types to appropriate contexts + // Pass null for shared connection since these contexts use separate connections + var provider = new ThreeWayPolicyTypeProvider(policyContext, groupingContext, roleContext, null); + + // Create adapter and enforcer + var adapter = new EFCoreAdapter(provider); + var enforcer = new Enforcer(ModelPath, adapter); + + await enforcer.LoadPolicyAsync(); + + return enforcer; + } + + private CasbinDbContext CreateContext(NpgsqlConnection connection, string schemaName) + { + var options = new DbContextOptionsBuilder>() + .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", schemaName)) + .Options; + + // Return appropriate derived context based on schema name + if (schemaName == TransactionIntegrityTestFixture.PoliciesSchema) + return new TestCasbinDbContext1(options, schemaName, "casbin_rule"); + else if (schemaName == TransactionIntegrityTestFixture.GroupingsSchema) + return new TestCasbinDbContext2(options, schemaName, "casbin_rule"); + else if (schemaName == TransactionIntegrityTestFixture.RolesSchema) + return new TestCasbinDbContext3(options, schemaName, "casbin_rule"); + else + throw new ArgumentException($"Unknown schema name: {schemaName}", nameof(schemaName)); + } + + #endregion + + #region Test 1: Atomicity - Happy Path + + [Fact] + public async Task SavePolicy_WithSharedConnection_ShouldWriteToAllContextsAtomically() + { + // Arrange + var (enforcer, connection) = await CreateEnforcerWithSharedConnectionAsync(); + + try + { + // Add policies that will route to different contexts + await enforcer.AddPolicyAsync("alice", "data1", "read"); // → policies schema (p) + await enforcer.AddGroupingPolicyAsync("alice", "admin"); // → groupings schema (g) + await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser"); // → roles schema (g2) + + // Act + await enforcer.SavePolicyAsync(); + + // Assert - Verify each schema has exactly the expected policies + var policiesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.PoliciesSchema, "p"); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.GroupingsSchema, "g"); + var rolesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.RolesSchema, "g2"); + + Assert.Equal(1, policiesCount); + Assert.Equal(1, groupingsCount); + Assert.Equal(1, rolesCount); + } + finally + { + await connection.CloseAsync(); + await connection.DisposeAsync(); + } + } + + #endregion + + #region Test 2: Connection Sharing Verification + + [Fact] + public async Task MultiContextSetup_WithSharedConnection_ShouldShareSamePhysicalConnection() + { + // Arrange + var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + + try + { + var policyContext = CreateContext(connection, TransactionIntegrityTestFixture.PoliciesSchema); + var groupingContext = CreateContext(connection, TransactionIntegrityTestFixture.GroupingsSchema); + var roleContext = CreateContext(connection, TransactionIntegrityTestFixture.RolesSchema); + + // Act & Assert - Verify reference equality (not just connection string equality) + var policyConn = policyContext.Database.GetDbConnection(); + var groupingConn = groupingContext.Database.GetDbConnection(); + var roleConn = roleContext.Database.GetDbConnection(); + + Assert.Same(connection, policyConn); + Assert.Same(connection, groupingConn); + Assert.Same(connection, roleConn); + Assert.Same(policyConn, groupingConn); + Assert.Same(groupingConn, roleConn); + } + finally + { + await connection.CloseAsync(); + await connection.DisposeAsync(); + } + } + + #endregion + + #region Test 3: Rollback - Missing Table (CRITICAL TEST) + + [Fact] + public async Task SavePolicy_WhenTableDroppedInOneContext_ShouldRollbackAllContexts() + { + // Arrange + var (enforcer, connection) = await CreateEnforcerWithSharedConnectionAsync(); + + try + { + // Disable AutoSave so policies stay in-memory until SavePolicyAsync() is called + enforcer.EnableAutoSave(false); + + // Add policies to all contexts (in memory only, AutoSave is OFF) + await enforcer.AddPolicyAsync("alice", "data1", "read"); // → policies schema + await enforcer.AddGroupingPolicyAsync("alice", "admin"); // → groupings schema + await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser"); // → roles schema + + // Drop table in roles schema AFTER policies are in memory but BEFORE SavePolicy + // This simulates a catastrophic failure scenario where database schema is inconsistent + await _fixture.DropTableAsync(TransactionIntegrityTestFixture.RolesSchema); + + Exception? caughtException = null; + + // Act - Try to save, should throw due to missing table in roles schema + try + { + await enforcer.SavePolicyAsync(); + } + catch (Exception ex) + { + caughtException = ex; + } + + // Assert - Verify exception was thrown + Assert.NotNull(caughtException); + + // Recreate table for verification queries + await _fixture.RunMigrationsAsync(); + + // CRITICAL ASSERTION - Verify ZERO policies in all contexts (rollback successful) + // This proves that when one context fails, ALL contexts roll back atomically + var policiesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.PoliciesSchema, "p"); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.GroupingsSchema, "g"); + var rolesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.RolesSchema, "g2"); + + // Verify atomicity: All contexts rolled back (no partial commits) + Assert.Equal(0, policiesCount); // Should be 0 (rolled back) + Assert.Equal(0, groupingsCount); // Should be 0 (rolled back) + Assert.Equal(0, rolesCount); // Should be 0 (rolled back) + + // If we got here, atomicity is PROVEN! + } + finally + { + await connection.CloseAsync(); + await connection.DisposeAsync(); + + // Restore table for subsequent tests + await _fixture.RunMigrationsAsync(); + } + } + + #endregion + + #region Test 4: Rollback - Missing Table + + [Fact] + public async Task SavePolicy_WhenTableMissingInOneContext_ShouldRollbackAllContexts() + { + // Arrange + var (enforcer, connection) = await CreateEnforcerWithSharedConnectionAsync(); + + try + { + // Disable AutoSave so policies stay in-memory until SavePolicyAsync() is called + enforcer.EnableAutoSave(false); + + // Add policies to all contexts + await enforcer.AddPolicyAsync("alice", "data1", "read"); + await enforcer.AddGroupingPolicyAsync("alice", "admin"); + await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser"); + + // Drop table in roles schema AFTER enforcer is created + await _fixture.DropTableAsync(TransactionIntegrityTestFixture.RolesSchema); + + Exception? caughtException = null; + + // Act - Try to save, should throw due to missing table + try + { + await enforcer.SavePolicyAsync(); + } + catch (Exception ex) + { + caughtException = ex; + } + + // Assert - Verify exception was thrown + Assert.NotNull(caughtException); + + // Recreate table for verification queries + await _fixture.RunMigrationsAsync(); + + // CRITICAL ASSERTION - Verify ZERO policies in all contexts (rollback successful) + var policiesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.PoliciesSchema, "p"); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.GroupingsSchema, "g"); + var rolesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.RolesSchema, "g2"); + + Assert.Equal(0, policiesCount); + Assert.Equal(0, groupingsCount); + Assert.Equal(0, rolesCount); + } + finally + { + await connection.CloseAsync(); + await connection.DisposeAsync(); + + // Restore table for subsequent tests + await _fixture.RunMigrationsAsync(); + } + } + + #endregion + + #region Test 5: Consistency Verification + + [Fact] + public async Task MultipleSaveOperations_WithSharedConnection_ShouldMaintainDataConsistency() + { + // Arrange + var (enforcer, connection) = await CreateEnforcerWithSharedConnectionAsync(); + + try + { + // Act - Perform multiple incremental saves + // Save 1 + await enforcer.AddPolicyAsync("alice", "data1", "read"); + await enforcer.AddGroupingPolicyAsync("alice", "admin"); + await enforcer.SavePolicyAsync(); + + // Save 2 + await enforcer.AddPolicyAsync("bob", "data2", "write"); + await enforcer.AddGroupingPolicyAsync("bob", "user"); + await enforcer.SavePolicyAsync(); + + // Save 3 + await enforcer.AddPolicyAsync("charlie", "data3", "read"); + await enforcer.AddGroupingPolicyAsync("charlie", "user"); + await enforcer.SavePolicyAsync(); + + // Assert - Verify all 6 policies present (3 p policies, 3 g policies) + var policiesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.PoliciesSchema, "p"); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.GroupingsSchema, "g"); + + Assert.Equal(3, policiesCount); + Assert.Equal(3, groupingsCount); + + // Verify all policies are enforced correctly + Assert.True(await enforcer.EnforceAsync("alice", "data1", "read")); + Assert.True(await enforcer.EnforceAsync("bob", "data2", "write")); + Assert.True(await enforcer.EnforceAsync("charlie", "data3", "read")); + } + finally + { + await connection.CloseAsync(); + await connection.DisposeAsync(); + } + } + + #endregion + + #region Test 6: Non-Atomic Behavior Without Shared Connection + + [Fact] + public async Task SavePolicy_WithSeparateConnections_ShouldNotBeAtomic() + { + // Arrange - Create enforcer with SEPARATE connection objects + var enforcer = await CreateEnforcerWithSeparateConnectionsAsync(); + + // Add policies to all contexts + await enforcer.AddPolicyAsync("alice", "data1", "read"); + await enforcer.AddGroupingPolicyAsync("alice", "admin"); + await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser"); + + // Drop table in roles schema to force failure + await _fixture.DropTableAsync(TransactionIntegrityTestFixture.RolesSchema); + + Exception? caughtException = null; + + // Act - Try to save, should throw due to missing table in roles schema + try + { + await enforcer.SavePolicyAsync(); + } + catch (Exception ex) + { + caughtException = ex; + } + + // Assert - Verify exception was thrown + Assert.NotNull(caughtException); + + // Recreate table for verification queries + await _fixture.RunMigrationsAsync(); + + // CRITICAL ASSERTION - Verify policies WERE written to functioning contexts (NOT atomic!) + var policiesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.PoliciesSchema, "p"); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.GroupingsSchema, "g"); + var rolesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.RolesSchema, "g2"); + + // This test DOCUMENTS the non-atomic behavior without shared connections + // Policies and groupings were committed despite roles context failure + Assert.Equal(1, policiesCount); // Written (NOT rolled back) + Assert.Equal(1, groupingsCount); // Written (NOT rolled back) + Assert.Equal(0, rolesCount); // Failed to write (table dropped) + + // This proves that connection string matching alone is INSUFFICIENT for atomicity + // Must use shared DbConnection OBJECT for atomic transactions + } + + #endregion + + #region Test 7: Casbin In-Memory vs Database State + + [Fact] + public async Task SavePolicy_ShouldReflectDatabaseStateNotCasbinMemory() + { + // Arrange - Create first enforcer and save policies + var (enforcer1, connection1) = await CreateEnforcerWithSharedConnectionAsync(); + + try + { + await enforcer1.AddPolicyAsync("alice", "data1", "read"); + await enforcer1.AddGroupingPolicyAsync("alice", "admin"); + await enforcer1.SavePolicyAsync(); + } + finally + { + await connection1.CloseAsync(); + await connection1.DisposeAsync(); + } + + // Create second enforcer - loads existing policies from database + var (enforcer2, connection2) = await CreateEnforcerWithSharedConnectionAsync(); + + try + { + // Act - Try to add same policies again + var addedPolicy = await enforcer2.AddPolicyAsync("alice", "data1", "read"); + var addedGrouping = await enforcer2.AddGroupingPolicyAsync("alice", "admin"); + + // Assert - Casbin's in-memory check should prevent duplicates + Assert.False(addedPolicy); + Assert.False(addedGrouping); + + // Verify database unchanged (validates tests check database, not just Casbin memory) + var policiesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.PoliciesSchema, "p"); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.GroupingsSchema, "g"); + + Assert.Equal(1, policiesCount); + Assert.Equal(1, groupingsCount); + } + finally + { + await connection2.CloseAsync(); + await connection2.DisposeAsync(); + } + } + + #endregion + } +} diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/BackwardCompatibilityTest.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/BackwardCompatibilityTest.cs index 5cbabdc..702958f 100644 --- a/Casbin.Persist.Adapter.EFCore.UnitTest/BackwardCompatibilityTest.cs +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/BackwardCompatibilityTest.cs @@ -1,5 +1,6 @@ using System.Linq; using System.Threading.Tasks; +using Casbin.Model; using Casbin.Persist.Adapter.EFCore.Entities; using Casbin.Persist.Adapter.EFCore.UnitTest.Extensions; using Casbin.Persist.Adapter.EFCore.UnitTest.Fixtures; @@ -227,10 +228,7 @@ public void TestSingleContextFilteredLoading() var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); // Act - Load only alice's policies - enforcer.LoadFilteredPolicy(new Filter - { - P = AsList("alice", "", "") - }); + enforcer.LoadFilteredPolicy(new SimpleFieldFilter("p", 0, Policy.ValuesFrom(AsList("alice", "", "")))); // Assert Assert.True(adapter.IsFiltered); diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Casbin.Persist.Adapter.EFCore.UnitTest.csproj b/Casbin.Persist.Adapter.EFCore.UnitTest/Casbin.Persist.Adapter.EFCore.UnitTest.csproj index 6253357..0c5dd09 100644 --- a/Casbin.Persist.Adapter.EFCore.UnitTest/Casbin.Persist.Adapter.EFCore.UnitTest.csproj +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Casbin.Persist.Adapter.EFCore.UnitTest.csproj @@ -4,6 +4,7 @@ net9.0;net8.0;net7.0;net6.0;net5.0;netcoreapp3.1; false 11 + $(NoWarn);NU1701 @@ -15,7 +16,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -72,6 +73,9 @@ Always + + PreserveNewest + diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/PolicyTypeContextProvider.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/PolicyTypeContextProvider.cs index 8d1e1b4..d01773f 100644 --- a/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/PolicyTypeContextProvider.cs +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/PolicyTypeContextProvider.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using Microsoft.EntityFrameworkCore; +#nullable enable + namespace Casbin.Persist.Adapter.EFCore.UnitTest.Fixtures { /// diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/SimpleFieldFilter.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/SimpleFieldFilter.cs new file mode 100644 index 0000000..78f2d5f --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/SimpleFieldFilter.cs @@ -0,0 +1,36 @@ +using System.Linq; +using Casbin.Model; +using Casbin.Persist; + +#nullable enable + +namespace Casbin.Persist.Adapter.EFCore.UnitTest.Fixtures +{ + /// + /// Simple field-based policy filter for testing. + /// Replaces the deprecated Filter class for basic field filtering scenarios. + /// + public class SimpleFieldFilter : IPolicyFilter + { + private readonly PolicyFilter _policyFilter; + + /// + /// Creates a filter that filters policies of the specified type by field values. + /// + /// The policy type to filter (e.g., "p", "g", "g2") + /// The field index to start filtering from (usually 0) + /// The field values to filter by + public SimpleFieldFilter(string policyType, int fieldIndex, IPolicyValues values) + { + _policyFilter = new PolicyFilter(policyType, fieldIndex, values); + } + + /// + /// Applies the filter to the policy collection. + /// + public IQueryable Apply(IQueryable policies) where T : IPersistPolicy + { + return _policyFilter.Apply(policies); + } + } +} diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/MultiContextTest.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/MultiContextTest.cs index 78e0977..4c9e294 100644 --- a/Casbin.Persist.Adapter.EFCore.UnitTest/MultiContextTest.cs +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/MultiContextTest.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Casbin.Model; using Casbin.Persist.Adapter.EFCore.Entities; using Casbin.Persist.Adapter.EFCore.UnitTest.Extensions; using Casbin.Persist.Adapter.EFCore.UnitTest.Fixtures; @@ -329,10 +330,7 @@ public void TestMultiContextLoadFilteredPolicy() var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter); // Act - Load only alice's policies - enforcer.LoadFilteredPolicy(new Filter - { - P = AsList("alice", "", "") - }); + enforcer.LoadFilteredPolicy(new SimpleFieldFilter("p", 0, Policy.ValuesFrom(AsList("alice", "", "")))); // Assert TestGetPolicy(enforcer, AsList( diff --git a/Casbin.Persist.Adapter.EFCore/ICasbinDbContextProvider.cs b/Casbin.Persist.Adapter.EFCore/ICasbinDbContextProvider.cs index d35bc9a..83525b5 100644 --- a/Casbin.Persist.Adapter.EFCore/ICasbinDbContextProvider.cs +++ b/Casbin.Persist.Adapter.EFCore/ICasbinDbContextProvider.cs @@ -3,6 +3,8 @@ using System.Data.Common; using Microsoft.EntityFrameworkCore; +#nullable enable + namespace Casbin.Persist.Adapter.EFCore { /// diff --git a/Casbin.Persist.Adapter.EFCore/SingleContextProvider.cs b/Casbin.Persist.Adapter.EFCore/SingleContextProvider.cs index b583bdb..48d87c6 100644 --- a/Casbin.Persist.Adapter.EFCore/SingleContextProvider.cs +++ b/Casbin.Persist.Adapter.EFCore/SingleContextProvider.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using Microsoft.EntityFrameworkCore; +#nullable enable + namespace Casbin.Persist.Adapter.EFCore { /// diff --git a/MULTI_CONTEXT_USAGE_GUIDE.md b/MULTI_CONTEXT_USAGE_GUIDE.md index 18d0a71..31e7eda 100644 --- a/MULTI_CONTEXT_USAGE_GUIDE.md +++ b/MULTI_CONTEXT_USAGE_GUIDE.md @@ -217,14 +217,54 @@ await enforcer.LoadPolicyAsync(); ### Filtered Loading -Load subsets of policies across all contexts: +Load subsets of policies across all contexts by implementing `IPolicyFilter`: ```csharp -enforcer.LoadFilteredPolicy(new Filter +using Casbin.Model; +using Casbin.Persist; + +// Create a custom filter for specific field values +public class SimpleFieldFilter : IPolicyFilter { - P = new[] { "alice", "", "" }, // Only Alice's policies - G = new[] { "alice", "" } // Only Alice's groupings -}); + private readonly PolicyFilter _policyFilter; + + public SimpleFieldFilter(string policyType, int fieldIndex, IPolicyValues values) + { + _policyFilter = new PolicyFilter(policyType, fieldIndex, values); + } + + public IQueryable Apply(IQueryable policies) where T : IPersistPolicy + { + return _policyFilter.Apply(policies); + } +} + +// Use the filter to load only Alice's p policies +enforcer.LoadFilteredPolicy( + new SimpleFieldFilter("p", 0, Policy.ValuesFrom(new[] { "alice", "", "" })) +); +``` + +For more complex filtering scenarios (e.g., domain-based filtering), implement `IPolicyFilter` directly: + +```csharp +public class DomainFilter : IPolicyFilter +{ + private readonly string _domain; + + public DomainFilter(string domain) => _domain = domain; + + public IQueryable Apply(IQueryable policies) where T : IPersistPolicy + { + return policies.Where(p => + (p.Type == "p" && p.Value2 == _domain) || // Filter p policies by domain + (p.Type == "g" && p.Value3 == _domain) // Filter g policies by domain + ); + } +} + +// Load policies for a specific domain +enforcer.LoadFilteredPolicy(new DomainFilter("tenant-123")); ``` ### Dependency Injection From 5d572e4eab0b91990695f0054fd8c04a0931a470 Mon Sep 17 00:00:00 2001 From: Thor Arne Johansen Date: Wed, 12 Nov 2025 16:36:01 +0100 Subject: [PATCH 03/11] docs: Clarify multi-context schema/table control and add framework support - Clarify that Context controls BOTH schema AND table independently - Add "Supported Frameworks" section listing all target frameworks: - .NET 9.0, 8.0, 7.0, 6.0, 5.0, and .NET Core 3.1 - Improve multi-context documentation accuracy Addresses documentation review feedback to ensure accuracy of implementation capabilities and framework compatibility. --- README.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 49fb6bb..19f7804 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,16 @@ You can see all the list at [Database Providers](https://docs.microsoft.com/en-g dotnet add package Casbin.NET.Adapter.EFCore ``` +## Supported Frameworks + +The adapter supports the following .NET target frameworks: +- .NET 9.0 +- .NET 8.0 +- .NET 7.0 +- .NET 6.0 +- .NET 5.0 +- .NET Core 3.1 + ## Simple Example ```csharp @@ -119,8 +129,8 @@ This approach resolves the DbContext from the service provider on each database ## Multi-Context Support The adapter supports storing different policy types in separate database contexts, allowing you to: -- Store policies (p, p2, etc.) and groupings (g, g2, etc.) in different schemas -- Use different tables for different policy types +- Store policies (p, p2, etc.) and groupings (g, g2, etc.) in different schemas and/or tables +- Each Context can control both schema AND table independently - Separate data for multi-tenant or compliance scenarios ### Quick Example From 707a44b9201ac35fb1f3931d0f166bc658591747 Mon Sep 17 00:00:00 2001 From: Thor Arne Johansen Date: Wed, 12 Nov 2025 16:41:22 +0100 Subject: [PATCH 04/11] chore: Remove TEST_SUMMARY.md from PR branch TEST_SUMMARY.md is an internal test documentation file that should not be part of the public PR. Test coverage details are already documented in PR_DESCRIPTION.md and the integration test README. --- TEST_SUMMARY.md | 177 ------------------------------------------------ 1 file changed, 177 deletions(-) delete mode 100644 TEST_SUMMARY.md diff --git a/TEST_SUMMARY.md b/TEST_SUMMARY.md deleted file mode 100644 index ad33a02..0000000 --- a/TEST_SUMMARY.md +++ /dev/null @@ -1,177 +0,0 @@ -# Multi-Context Test Suite Summary - -This document summarizes the comprehensive test suite created for the multi-context functionality. - -## Files Created - -### Core Implementation Files -1. **ICasbinDbContextProvider.cs** - Interface for context providers -2. **SingleContextProvider.cs** - Default single-context implementation (backward compatible) - -### Test Infrastructure -3. **PolicyTypeContextProvider.cs** - Test provider routing 'p' types to one context, 'g' types to another -4. **MultiContextProviderFixture.cs** - xUnit fixture for multi-context test setup - -### Test Files -5. **MultiContextTest.cs** - 17 comprehensive tests for multi-context scenarios -6. **BackwardCompatibilityTest.cs** - 12 tests ensuring existing code still works - -## Test Coverage - -### MultiContextTest.cs (17 Tests) - -#### CRUD Operations -- **TestMultiContextAddPolicy** - Verify policies route to correct contexts -- **TestMultiContextAddPolicyAsync** - Async version of add policy -- **TestMultiContextRemovePolicy** - Verify removal from correct contexts -- **TestMultiContextUpdatePolicy** - Verify updates in correct context - -#### Load/Save Operations -- **TestMultiContextLoadPolicy** - Load policies from multiple contexts -- **TestMultiContextLoadPolicyAsync** - Async load from multiple contexts -- **TestMultiContextSavePolicy** - Save and distribute policies to correct contexts -- **TestMultiContextSavePolicyAsync** - Async save across contexts - -#### Batch Operations -- **TestMultiContextBatchOperations** - Add/remove multiple policies across contexts - -#### Filtering -- **TestMultiContextLoadFilteredPolicy** - Filter policies from multiple contexts - -#### Transaction Tests -- **TestMultiContextTransactionRollback** - Verify transaction integrity across contexts - -#### Provider Tests -- **TestMultiContextProviderGetAllContexts** - Verify GetAllContexts returns correct count -- **TestMultiContextProviderGetContextForPolicyType** - Verify routing logic for different policy types - -### BackwardCompatibilityTest.cs (12 Tests) - -#### Constructor Compatibility -- **TestSingleContextConstructorStillWorks** - Original constructor API still works -- **TestSingleContextAsyncOperationsStillWork** - Async operations work with single context - -#### Operations -- **TestSingleContextLoadAndSave** - Load/save in single context -- **TestSingleContextWithExistingTests** - Match patterns from existing AutoTest.cs -- **TestSingleContextRemoveOperations** - Remove policies in single context -- **TestSingleContextUpdateOperations** - Update policies in single context -- **TestSingleContextBatchOperations** - Batch add/remove in single context -- **TestSingleContextFilteredLoading** - Filtered loading in single context - -#### Provider Tests -- **TestSingleContextProviderWrapping** - SingleContextProvider behaves like direct constructor -- **TestSingleContextProviderGetAllContexts** - Returns single context -- **TestSingleContextProviderGetContextForPolicyType** - All types return same context - -## Test Scenarios Covered - -### Multi-Context Scenarios - -1. **Separate Tables**: Policies in `casbin_policy` table, groupings in `casbin_grouping` table -2. **Cross-Context Operations**: Operations that span multiple contexts -3. **Transaction Integrity**: Ensuring ACID properties across contexts -4. **Context Routing**: Verifying correct context selection based on policy type - -### Backward Compatibility Scenarios - -1. **Single Context Usage**: All existing patterns continue to work -2. **Original Constructor**: Direct context passing still works -3. **All Operations**: CRUD, batch, filtering all work as before -4. **Provider Wrapping**: Explicit SingleContextProvider matches implicit behavior - -## Important Notes - -### Tests Will Initially Fail - -⚠️ **These tests are written in TDD (Test-Driven Development) style.** They will fail until the actual multi-context implementation is added to `EFCoreAdapter.cs`. - -The tests define the expected behavior and API. Implementation needs to: - -1. Add `ICasbinDbContextProvider` field to EFCoreAdapter -2. Add new constructor accepting context provider -3. Modify all CRUD operations to route to correct context -4. Implement shared transaction logic for operations spanning multiple contexts -5. Update virtual methods to support context-aware behavior - -### Build Requirements - -The project targets multiple frameworks including .NET 9.0. To build: -- Install .NET 9.0 SDK, or -- Remove net9.0 from TargetFrameworks in .csproj files temporarily - -### Test Database - -Tests use SQLite with separate database files per test to avoid conflicts: -- Single context tests: `{testName}.db` -- Multi-context tests: `MultiContext_{testName}.db` with two tables - -## Running Tests - -Once implementation is complete: - -```bash -# Run all tests -dotnet test - -# Run only multi-context tests -dotnet test --filter "FullyQualifiedName~MultiContextTest" - -# Run only backward compatibility tests -dotnet test --filter "FullyQualifiedName~BackwardCompatibilityTest" - -# Run specific test -dotnet test --filter "FullyQualifiedName~TestMultiContextAddPolicy" -``` - -## Test Strategy - -### Phase 1: Provider Infrastructure ✅ -- [x] Create ICasbinDbContextProvider interface -- [x] Create SingleContextProvider implementation -- [x] Create test providers and fixtures - -### Phase 2: Write Tests ✅ -- [x] Multi-context CRUD tests -- [x] Multi-context transaction tests -- [x] Backward compatibility tests -- [x] Provider behavior tests - -### Phase 3: Implementation (Next Steps) -- [ ] Update EFCoreAdapter constructors -- [ ] Implement context routing logic -- [ ] Implement shared transaction handling -- [ ] Run tests and iterate until all pass - -### Phase 4: Integration -- [ ] Run existing AutoTest.cs to ensure no regressions -- [ ] Run all test suites together -- [ ] Update documentation with examples - -## Expected Test Results After Implementation - -After implementation is complete, all 29 tests should pass: -- ✅ 17 multi-context tests passing -- ✅ 12 backward compatibility tests passing -- ✅ All existing tests still passing (no regressions) - -## Key Test Assertions - -### Multi-Context Tests Verify: -- Policies route to correct contexts based on policy type -- All contexts participate in shared transactions -- Load operations merge data from all contexts -- Save operations distribute data to correct contexts -- Filtering works across multiple contexts - -### Backward Compatibility Tests Verify: -- Original constructor works identically -- Single context contains all policy types -- All operations produce same results as before -- SingleContextProvider behaves transparently - ---- - -**Document Status:** Test Suite Complete - Ready for Implementation -**Test Count:** 29 tests (17 multi-context + 12 backward compatibility) -**Last Updated:** 2025-10-14 From 467c6e547d607dd4adfa20d123c4767a875b1c8c Mon Sep 17 00:00:00 2001 From: Thor Arne Johansen Date: Wed, 12 Nov 2025 16:48:18 +0100 Subject: [PATCH 05/11] refactor: reorganize integration tests into separate project - Move integration tests to dedicated Casbin.Persist.Adapter.EFCore.IntegrationTest project - Add xunit.runner.json configuration for both test projects - Remove ServiceProviderContextProvider (consolidated into main implementation) - Update solution file to reflect new project structure --- .../Integration/IntegrationTestCollection.cs | 0 .../Integration/README.md | 14 + .../TransactionIntegrityTestFixture.cs | 0 .../examples/multi_context_model.conf | 15 + .../xunit.runner.json | 5 + .../Fixtures/TestHostFixture.cs | 5 +- .../Integration/AutoSaveTests.cs | 982 ------------------ .../Integration/SchemaDistributionTests.cs | 340 ------ .../Integration/TransactionIntegrityTests.cs | 576 ---------- .../xunit.runner.json | 5 + .../EFCoreAdapter.cs | 13 +- .../ServiceProviderContextProvider.cs | 54 - EFCore-Adapter.sln | 14 + 13 files changed, 66 insertions(+), 1957 deletions(-) rename {Casbin.Persist.Adapter.EFCore.UnitTest => Casbin.Persist.Adapter.EFCore.IntegrationTest}/Integration/IntegrationTestCollection.cs (100%) rename {Casbin.Persist.Adapter.EFCore.UnitTest => Casbin.Persist.Adapter.EFCore.IntegrationTest}/Integration/README.md (94%) rename {Casbin.Persist.Adapter.EFCore.UnitTest => Casbin.Persist.Adapter.EFCore.IntegrationTest}/Integration/TransactionIntegrityTestFixture.cs (100%) create mode 100644 Casbin.Persist.Adapter.EFCore.IntegrationTest/examples/multi_context_model.conf create mode 100644 Casbin.Persist.Adapter.EFCore.IntegrationTest/xunit.runner.json delete mode 100644 Casbin.Persist.Adapter.EFCore.UnitTest/Integration/AutoSaveTests.cs delete mode 100644 Casbin.Persist.Adapter.EFCore.UnitTest/Integration/SchemaDistributionTests.cs delete mode 100644 Casbin.Persist.Adapter.EFCore.UnitTest/Integration/TransactionIntegrityTests.cs create mode 100644 Casbin.Persist.Adapter.EFCore.UnitTest/xunit.runner.json delete mode 100644 Casbin.Persist.Adapter.EFCore/ServiceProviderContextProvider.cs diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/IntegrationTestCollection.cs b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/IntegrationTestCollection.cs similarity index 100% rename from Casbin.Persist.Adapter.EFCore.UnitTest/Integration/IntegrationTestCollection.cs rename to Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/IntegrationTestCollection.cs diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/README.md b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/README.md similarity index 94% rename from Casbin.Persist.Adapter.EFCore.UnitTest/Integration/README.md rename to Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/README.md index 53111a5..8b8b975 100644 --- a/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/README.md +++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/README.md @@ -2,6 +2,20 @@ This directory contains integration tests that verify the transaction integrity guarantees of the multi-context EFCore adapter feature. +## Separate Test Project + +These integration tests are in a **separate test project** (`Casbin.Persist.Adapter.EFCore.IntegrationTest`) to enable sequential framework execution. + +**Why separate?** +- Integration tests run frameworks **sequentially** (one at a time) to avoid PostgreSQL database conflicts +- Unit tests continue running frameworks **in parallel** for faster execution +- .NET 9+ runs multi-targeted tests in parallel by default - this separation allows different configurations + +**Project Settings:** +- `false` - Frameworks execute sequentially +- Shares single PostgreSQL database: `casbin_integration_test` +- Uses `DisableParallelization = true` on test collection for within-framework sequencing + ## ✅ Casbin.NET AutoSave Bug - FIXED in v2.19.1 ### Test Status Summary diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/TransactionIntegrityTestFixture.cs b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTestFixture.cs similarity index 100% rename from Casbin.Persist.Adapter.EFCore.UnitTest/Integration/TransactionIntegrityTestFixture.cs rename to Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTestFixture.cs diff --git a/Casbin.Persist.Adapter.EFCore.IntegrationTest/examples/multi_context_model.conf b/Casbin.Persist.Adapter.EFCore.IntegrationTest/examples/multi_context_model.conf new file mode 100644 index 0000000..4e8b46d --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/examples/multi_context_model.conf @@ -0,0 +1,15 @@ +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ +g2 = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act diff --git a/Casbin.Persist.Adapter.EFCore.IntegrationTest/xunit.runner.json b/Casbin.Persist.Adapter.EFCore.IntegrationTest/xunit.runner.json new file mode 100644 index 0000000..6b2d9df --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeTestCollections": true, + "maxParallelThreads": -1 +} diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/TestHostFixture.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/TestHostFixture.cs index 97faf7d..b436420 100644 --- a/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/TestHostFixture.cs +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/TestHostFixture.cs @@ -10,10 +10,13 @@ public class TestHostFixture { public TestHostFixture() { + // Use unique database name to allow parallel test execution + var uniqueDbName = $"CasbinHostTest_{Guid.NewGuid():N}.db"; + Services = new ServiceCollection() .AddDbContext>(options => { - options.UseSqlite("Data Source=CasbinHostTest.db"); + options.UseSqlite($"Data Source={uniqueDbName}"); }) .AddEFCoreAdapter() .BuildServiceProvider(); diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/AutoSaveTests.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/AutoSaveTests.cs deleted file mode 100644 index 4a0884f..0000000 --- a/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/AutoSaveTests.cs +++ /dev/null @@ -1,982 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Casbin.Model; -using Casbin.Persist.Adapter.EFCore.Entities; -using Npgsql; -using Microsoft.EntityFrameworkCore; -using Xunit; -using Xunit.Abstractions; - -namespace Casbin.Persist.Adapter.EFCore.UnitTest.Integration -{ - /// - /// Custom context classes for multi-schema testing - /// - public class TestCasbinDbContext1 : CasbinDbContext - { - public TestCasbinDbContext1( - DbContextOptions> options, - string schemaName, - string tableName) - : base(options, schemaName, tableName) - { - } - } - - public class TestCasbinDbContext2 : CasbinDbContext - { - public TestCasbinDbContext2( - DbContextOptions> options, - string schemaName, - string tableName) - : base(options, schemaName, tableName) - { - } - } - - public class TestCasbinDbContext3 : CasbinDbContext - { - public TestCasbinDbContext3( - DbContextOptions> options, - string schemaName, - string tableName) - : base(options, schemaName, tableName) - { - } - } - - /// - /// Integration tests for AutoSave behavior using PostgreSQL. - /// These tests verify that the adapter correctly handles AutoSave ON and OFF modes - /// when working with both regular policies and grouping policies. - /// - /// Note: These are integration tests (not unit tests) because they: - /// - Use the full Casbin Enforcer (not just the adapter in isolation) - /// - Test the interaction between Enforcer and Adapter - /// - Use real PostgreSQL database (not SQLite in-memory) - /// - [Trait("Category", "Integration")] - [Collection("IntegrationTests")] - public class AutoSaveTests : TestUtil, IAsyncLifetime - { - private readonly TransactionIntegrityTestFixture _fixture; - private readonly ITestOutputHelper _output; - private const string ModelPath = "examples/multi_context_model.conf"; - - public AutoSaveTests(TransactionIntegrityTestFixture fixture, ITestOutputHelper output) - { - _fixture = fixture; - _output = output; - } - - public async Task InitializeAsync() - { - // Clear all policies before each test - await _fixture.ClearAllPoliciesAsync(); - } - - public async Task DisposeAsync() - { - // Restore any tables that may have been dropped during test execution - await _fixture.RunMigrationsAsync(); - } - - /// - /// Tests regular policies with AutoSave ON (default behavior). - /// Verifies that operations immediately persist to the database. - /// - [Fact] - public async Task TestPolicyAutoSaveOn() - { - await using var connection = new NpgsqlConnection(_fixture.ConnectionString); - await connection.OpenAsync(); - - var options = new DbContextOptionsBuilder>() - .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.PoliciesSchema)) - .Options; - - await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.PoliciesSchema); - await InitPolicyAsync(context); - - var adapter = new EFCoreAdapter(context); - var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); - var enforcer = new Enforcer(model, adapter); - - #region Load policy test - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write"), - AsList("data2_admin", "data2", "read"), - AsList("data2_admin", "data2", "write") - )); - Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5); - #endregion - - #region Add policy test - await enforcer.AddPolicyAsync("alice", "data1", "write"); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write"), - AsList("data2_admin", "data2", "read"), - AsList("data2_admin", "data2", "write"), - AsList("alice", "data1", "write") - )); - Assert.True(await context.Policies.AsNoTracking().CountAsync() is 6); - #endregion - - #region Remove policy test - await enforcer.RemovePolicyAsync("alice", "data1", "write"); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write"), - AsList("data2_admin", "data2", "read"), - AsList("data2_admin", "data2", "write") - )); - Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5); - - await enforcer.RemoveFilteredPolicyAsync(0, "data2_admin"); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write") - )); - Assert.True(await context.Policies.AsNoTracking().CountAsync() is 3); - #endregion - - #region Update policy test - await enforcer.UpdatePolicyAsync(AsList("alice", "data1", "read"), - AsList("alice", "data2", "write")); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data2", "write"), - AsList("bob", "data2", "write") - )); - Assert.True(await context.Policies.AsNoTracking().CountAsync() is 3); - - await enforcer.UpdatePolicyAsync(AsList("alice", "data2", "write"), - AsList("alice", "data1", "read")); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write") - )); - Assert.True(await context.Policies.AsNoTracking().CountAsync() is 3); - #endregion - - #region Batch APIs test - await enforcer.AddPoliciesAsync(new [] - { - new System.Collections.Generic.List{"alice", "data2", "write"}, - new System.Collections.Generic.List{"bob", "data1", "read"} - }); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write"), - AsList("alice", "data2", "write"), - AsList("bob", "data1", "read") - )); - Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5); - - await enforcer.RemovePoliciesAsync(new [] - { - new System.Collections.Generic.List{"alice", "data1", "read"}, - new System.Collections.Generic.List{"bob", "data2", "write"} - }); - TestGetPolicy(enforcer, AsList( - AsList("alice", "data2", "write"), - AsList("bob", "data1", "read") - )); - Assert.True(await context.Policies.AsNoTracking().CountAsync() is 3); - #endregion - } - - /// - /// Tests async version of regular policies with AutoSave ON. - /// - [Fact] - public async Task TestPolicyAutoSaveOnAsync() - { - await using var connection = new NpgsqlConnection(_fixture.ConnectionString); - await connection.OpenAsync(); - - var options = new DbContextOptionsBuilder>() - .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.PoliciesSchema)) - .Options; - - await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.PoliciesSchema); - await InitPolicyAsync(context); - - var adapter = new EFCoreAdapter(context); - var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); - var enforcer = new Enforcer(model, adapter); - - #region Load policy test - TestGetPolicy(enforcer, AsList( - AsList("alice", "data1", "read"), - AsList("bob", "data2", "write"), - AsList("data2_admin", "data2", "read"), - AsList("data2_admin", "data2", "write") - )); - Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5); - #endregion - - #region Add policy test - await enforcer.AddPolicyAsync("alice", "data1", "write"); - Assert.True(await context.Policies.AsNoTracking().CountAsync() is 6); - #endregion - - #region Remove policy test - await enforcer.RemovePolicyAsync("alice", "data1", "write"); - Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5); - #endregion - } - - /// - /// Tests regular policies with AutoSave OFF. - /// Verifies that AddPolicy() correctly respects AutoSave OFF setting. - /// This documents the CORRECT behavior (contrast with grouping policy bug). - /// - [Fact] - public async Task TestPolicyAutoSaveOff() - { - await using var connection = new NpgsqlConnection(_fixture.ConnectionString); - await connection.OpenAsync(); - - var options = new DbContextOptionsBuilder>() - .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.PoliciesSchema)) - .Options; - - await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.PoliciesSchema); - await InitPolicyAsync(context); - - var adapter = new EFCoreAdapter(context); - var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); - var enforcer = new Enforcer(model, adapter); - - // Disable AutoSave - enforcer.EnableAutoSave(false); - - // Verify initial state - Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); - - // Add policy - should NOT save to database with AutoSave OFF - enforcer.AddPolicy("charlie", "data3", "read"); - - // Verify policy was NOT saved yet (correct behavior for regular policies) - var countAfterAdd = await context.Policies.AsNoTracking().CountAsync(); - Assert.Equal(5, countAfterAdd); // Still 5 - CORRECT BEHAVIOR - - // Verify policy is NOT in database yet - var charlieBeforeSave = await context.Policies.AsNoTracking() - .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie"); - Assert.Null(charlieBeforeSave); // Not in database yet - CORRECT - - // When SavePolicy is called, it should save the policy - await enforcer.SavePolicyAsync(); - - // Now the policy should be in database - Assert.Equal(6, await context.Policies.AsNoTracking().CountAsync()); // 5 + 1 = 6 - - // Verify it's in database after SavePolicy - var charlieAfterSave = await context.Policies.AsNoTracking() - .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie"); - Assert.NotNull(charlieAfterSave); - } - - /// - /// Tests async version of regular policies with AutoSave OFF. - /// Verifies that AddPolicyAsync() correctly respects AutoSave OFF setting. - /// - [Fact] - public async Task TestPolicyAutoSaveOffAsync() - { - await using var connection = new NpgsqlConnection(_fixture.ConnectionString); - await connection.OpenAsync(); - - var options = new DbContextOptionsBuilder>() - .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.PoliciesSchema)) - .Options; - - await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.PoliciesSchema); - await InitPolicyAsync(context); - - var adapter = new EFCoreAdapter(context); - var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); - var enforcer = new Enforcer(model, adapter); - - // Disable AutoSave - enforcer.EnableAutoSave(false); - - // Verify initial state - Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); - - // Add policy - should NOT save to database with AutoSave OFF - await enforcer.AddPolicyAsync("charlie", "data3", "read"); - - // Verify policy was NOT saved yet (correct behavior) - var countAfterAdd = await context.Policies.AsNoTracking().CountAsync(); - Assert.Equal(5, countAfterAdd); // Still 5 - CORRECT BEHAVIOR - - // When SavePolicy is called, it should save the policy - await enforcer.SavePolicyAsync(); - - // Now the policy should be in database - Assert.Equal(6, await context.Policies.AsNoTracking().CountAsync()); - - // Verify it's in database - var charlieAfterSave = await context.Policies.AsNoTracking() - .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie"); - Assert.NotNull(charlieAfterSave); - } - - /// - /// Tests grouping policies with AutoSave ON (default behavior). - /// This test verifies that AddGroupingPolicy() immediately saves to database. - /// - [Fact] - public async Task TestGroupingPolicyAutoSaveOn() - { - await using var connection = new NpgsqlConnection(_fixture.ConnectionString); - await connection.OpenAsync(); - - var options = new DbContextOptionsBuilder>() - .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.GroupingsSchema)) - .Options; - - await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.GroupingsSchema); - await InitPolicyAsync(context); - - var adapter = new EFCoreAdapter(context); - var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); - var enforcer = new Enforcer(model, adapter); - - // Verify initial grouping policy - TestGetGroupingPolicy(enforcer, AsList( - AsList("alice", "data2_admin") - )); - Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); - - // Add grouping policy - should save immediately with AutoSave ON - await enforcer.AddGroupingPolicyAsync("bob", "data2_admin"); - - // Verify it's in Casbin's memory - TestGetGroupingPolicy(enforcer, AsList( - AsList("alice", "data2_admin"), - AsList("bob", "data2_admin") - )); - - // Verify it was saved to database immediately - Assert.Equal(6, await context.Policies.AsNoTracking().CountAsync()); - var bobGrouping = await context.Policies.AsNoTracking() - .FirstOrDefaultAsync(p => p.Type == "g" && p.Value1 == "bob" && p.Value2 == "data2_admin"); - Assert.NotNull(bobGrouping); - } - - /// - /// Tests grouping policies with AutoSave OFF. - /// - /// Verifies that AddGroupingPolicy() respects the EnableAutoSave(false) setting. - /// - /// Expected behavior (verified by this test): - /// - AddGroupingPolicy() should NOT save to database when AutoSave is OFF - /// - Only SavePolicy() should commit changes - /// - /// This test now passes with Casbin.NET 2.19.1+ which fixed the AutoSave bug. - /// - /// Related: Integration/README.md - /// - [Fact] - public async Task TestGroupingPolicyAutoSaveOff() - { - await using var connection = new NpgsqlConnection(_fixture.ConnectionString); - await connection.OpenAsync(); - - var options = new DbContextOptionsBuilder>() - .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.GroupingsSchema)) - .Options; - - await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.GroupingsSchema); - await InitPolicyAsync(context); - - var adapter = new EFCoreAdapter(context); - var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); - var enforcer = new Enforcer(model, adapter); - - // Disable AutoSave - enforcer.EnableAutoSave(false); - - // Verify initial state - Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); - - // Add regular policy - should NOT save to database with AutoSave OFF - enforcer.AddPolicy("charlie", "data3", "read"); - - // Verify regular policy was NOT saved (correct behavior) - Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); - - // Add grouping policy - should NOT save to database with AutoSave OFF - await enforcer.AddGroupingPolicyAsync("bob", "data2_admin"); - - // TEST EXPECTATION: Grouping policy should NOT be saved yet (AutoSave is OFF) - // BUG: This will fail because Casbin.NET incorrectly saves it (Actual: 6, Expected: 5) - var savedCountAfterAdd = await context.Policies.AsNoTracking().CountAsync(); - Assert.Equal(5, savedCountAfterAdd); // FAILS due to bug: actual is 6 - - // Verify the grouping policy is NOT in database yet - var bobGroupingBeforeSave = await context.Policies.AsNoTracking() - .FirstOrDefaultAsync(p => p.Type == "g" && p.Value1 == "bob" && p.Value2 == "data2_admin"); - Assert.Null(bobGroupingBeforeSave); // FAILS due to bug: it exists - - // When SavePolicy is called, it should save BOTH policies - await enforcer.SavePolicyAsync(); - - // Now both policies should be in database - Assert.Equal(7, await context.Policies.AsNoTracking().CountAsync()); // 5 original + 1 charlie + 1 bob = 7 - - // Verify both are in database after SavePolicy - var charliePolicy = await context.Policies.AsNoTracking() - .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie"); - var bobGroupingAfterSave = await context.Policies.AsNoTracking() - .FirstOrDefaultAsync(p => p.Type == "g" && p.Value1 == "bob" && p.Value2 == "data2_admin"); - - Assert.NotNull(charliePolicy); - Assert.NotNull(bobGroupingAfterSave); - } - - /// - /// Tests async version of grouping policies with AutoSave OFF. - /// - /// Verifies that AddGroupingPolicyAsync() respects the EnableAutoSave(false) setting. - /// - /// Expected behavior (verified by this test): AddGroupingPolicyAsync() should NOT save when AutoSave is OFF. - /// - /// This test now passes with Casbin.NET 2.19.1+ which fixed the AutoSave bug. - /// - [Fact] - public async Task TestGroupingPolicyAutoSaveOffAsync() - { - await using var connection = new NpgsqlConnection(_fixture.ConnectionString); - await connection.OpenAsync(); - - var options = new DbContextOptionsBuilder>() - .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.GroupingsSchema)) - .Options; - - await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.GroupingsSchema); - await InitPolicyAsync(context); - - var adapter = new EFCoreAdapter(context); - var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf")); - var enforcer = new Enforcer(model, adapter); - - // Disable AutoSave - enforcer.EnableAutoSave(false); - - // Verify initial state - Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); - - // Add regular policy - should NOT save to database with AutoSave OFF - await enforcer.AddPolicyAsync("charlie", "data3", "read"); - - // Verify regular policy was NOT saved (correct behavior) - Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync()); - - // Add grouping policy - should NOT save to database with AutoSave OFF - await enforcer.AddGroupingPolicyAsync("bob", "data2_admin"); - - // TEST EXPECTATION: Grouping policy should NOT be saved yet - // BUG: This will fail because Casbin.NET incorrectly saves it (Actual: 6, Expected: 5) - var savedCountAfterAdd = await context.Policies.AsNoTracking().CountAsync(); - Assert.Equal(5, savedCountAfterAdd); // FAILS due to bug: actual is 6 - - // When SavePolicy is called, it should save BOTH policies - await enforcer.SavePolicyAsync(); - - // Now both policies should be in database - Assert.Equal(7, await context.Policies.AsNoTracking().CountAsync()); // 5 original + 1 charlie + 1 bob = 7 - - // Verify both are in database - var charliePolicy = await context.Policies.AsNoTracking() - .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie"); - var bobGrouping = await context.Policies.AsNoTracking() - .FirstOrDefaultAsync(p => p.Type == "g" && p.Value1 == "bob" && p.Value2 == "data2_admin"); - - Assert.NotNull(charliePolicy); - Assert.NotNull(bobGrouping); - } - - #region Multi-Context AutoSave Tests - - /// - /// Provider that routes policy types to three separate contexts - /// - private class ThreeWayContextProvider : ICasbinDbContextProvider - { - private readonly CasbinDbContext _policyContext; - private readonly CasbinDbContext _groupingContext; - private readonly CasbinDbContext _roleContext; - private readonly System.Data.Common.DbConnection? _sharedConnection; - - public ThreeWayContextProvider( - CasbinDbContext policyContext, - CasbinDbContext groupingContext, - CasbinDbContext roleContext, - System.Data.Common.DbConnection? sharedConnection) - { - _policyContext = policyContext; - _groupingContext = groupingContext; - _roleContext = roleContext; - _sharedConnection = sharedConnection; - } - - public DbContext GetContextForPolicyType(string policyType) - { - return policyType switch - { - "p" => _policyContext, // p policies → casbin_policies schema - "g" => _groupingContext, // g groupings → casbin_groupings schema - "g2" => _roleContext, // g2 roles → casbin_roles schema - _ => _policyContext - }; - } - - public IEnumerable GetAllContexts() - { - return new[] { _policyContext, _groupingContext, _roleContext }; - } - - public System.Data.Common.DbConnection? GetSharedConnection() - { - return _sharedConnection; - } - } - - /// - /// Tests AutoSave OFF with multiple contexts and rollback on failure. - /// - /// Verifies that: - /// - With AutoSave OFF, policies batch in memory (not commit) - /// - SavePolicy() uses shared transaction and rolls back atomically on failure - /// - /// This test now passes with Casbin.NET 2.19.1+ which fixed the AutoSave bug. - /// - [Fact] - public async Task TestAutoSaveOff_MultiContext_RollbackOnFailure() - { - _output.WriteLine("=== AUTOSAVE OFF - MULTI-CONTEXT ATOMIC ROLLBACK TEST ==="); - _output.WriteLine("Goal: With AutoSave OFF, SavePolicy should use shared transaction and rollback atomically"); - _output.WriteLine(""); - - // Clear all data first - await _fixture.ClearAllPoliciesAsync(); - - // Create ONE shared connection - var sharedConnection = new NpgsqlConnection(_fixture.ConnectionString); - await sharedConnection.OpenAsync(); - - try - { - _output.WriteLine($"Shared connection: {sharedConnection.GetHashCode()}"); - _output.WriteLine(""); - - // Create three contexts using the SAME connection object - var options1 = new DbContextOptionsBuilder>() - .UseNpgsql(sharedConnection) - .Options; - var policyContext = new TestCasbinDbContext1(options1, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule"); - - var options2 = new DbContextOptionsBuilder>() - .UseNpgsql(sharedConnection) - .Options; - var groupingContext = new TestCasbinDbContext2(options2, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule"); - - var options3 = new DbContextOptionsBuilder>() - .UseNpgsql(sharedConnection) - .Options; - var roleContext = new TestCasbinDbContext3(options3, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule"); - - // Create provider and adapter - var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, sharedConnection); - var adapter = new EFCoreAdapter(provider); - - // Create enforcer and DISABLE AutoSave - var model = DefaultModel.CreateFromFile(ModelPath); - var enforcer = new Enforcer(model); - enforcer.SetAdapter(adapter); - enforcer.EnableAutoSave(false); // ← CRITICAL: Disable AutoSave - _output.WriteLine("AutoSave disabled"); - _output.WriteLine(""); - - // Add multiple policies to each type - _output.WriteLine("Adding policies with AutoSave OFF (should batch in memory):"); - enforcer.AddPolicy("alice", "data1", "read"); - enforcer.AddPolicy("bob", "data2", "write"); - _output.WriteLine(" Added 2 p policies"); - - enforcer.AddGroupingPolicy("alice", "admin"); - enforcer.AddGroupingPolicy("bob", "user"); - _output.WriteLine(" Added 2 g groupings"); - - enforcer.AddNamedGroupingPolicy("g2", "admin", "role-superuser"); - enforcer.AddNamedGroupingPolicy("g2", "user", "role-basic"); - _output.WriteLine(" Added 2 g2 roles"); - _output.WriteLine(""); - - // Check database state - should be EMPTY (policies batched, not committed) - var beforePoliciesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); - var beforeGroupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); - var beforeRolesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema); - _output.WriteLine($"STATE BEFORE DROP (should be 0,0,0): ({beforePoliciesCount}, {beforeGroupingsCount}, {beforeRolesCount})"); - - if (beforePoliciesCount == 0 && beforeGroupingsCount == 0 && beforeRolesCount == 0) - { - _output.WriteLine("✓ Confirmed: AutoSave OFF prevents immediate commits"); - } - else - { - _output.WriteLine("✗ WARNING: Policies were committed despite AutoSave OFF!"); - } - _output.WriteLine(""); - - // NOW: Drop the table for the third schema to force a failure - _output.WriteLine("FORCING FAILURE: Dropping casbin_roles.casbin_rule table..."); - await using (var cmd = sharedConnection.CreateCommand()) - { - cmd.CommandText = $"DROP TABLE {TransactionIntegrityTestFixture.RolesSchema}.casbin_rule"; - await cmd.ExecuteNonQueryAsync(); - } - _output.WriteLine("Table dropped!"); - _output.WriteLine(""); - - // Try to save - this SHOULD fail - _output.WriteLine("Calling SavePolicyAsync()... (expecting exception)"); - var exception = await Assert.ThrowsAnyAsync(async () => - { - await enforcer.SavePolicyAsync(); - }); - _output.WriteLine($"✓ Exception caught as expected: {exception.GetType().Name}"); - _output.WriteLine($" Message: {exception.Message}"); - _output.WriteLine(""); - - // Count policies in the first two schemas (third schema table is gone) - var policiesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); - var groupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); - - _output.WriteLine("RESULTS - Policies per schema after failure:"); - _output.WriteLine($" casbin_policies: {policiesCount}"); - _output.WriteLine($" casbin_groupings: {groupingsCount}"); - _output.WriteLine($" casbin_roles: N/A (table dropped)"); - _output.WriteLine(""); - - // ASSERT: With AutoSave OFF and shared connection, SavePolicy should roll back ALL changes - if (policiesCount == 0 && groupingsCount == 0) - { - _output.WriteLine("✓✓✓ AUTOSAVE OFF ATOMIC TRANSACTION TEST PASSED!"); - _output.WriteLine("SavePolicy used shared transaction and rolled back atomically"); - } - else - { - _output.WriteLine("✗✗✗ AUTOSAVE OFF ATOMIC TRANSACTION TEST FAILED!"); - _output.WriteLine($"Expected: (0, 0), Got: ({policiesCount}, {groupingsCount})"); - } - - Assert.Equal(0, policiesCount); - Assert.Equal(0, groupingsCount); - - await policyContext.DisposeAsync(); - await groupingContext.DisposeAsync(); - await roleContext.DisposeAsync(); - } - finally - { - await sharedConnection.DisposeAsync(); - } - } - - /// - /// Tests AutoSave ON with multiple contexts showing individual commits. - /// Verifies that each AddPolicy commits independently (no cross-context atomicity). - /// - [Fact] - public async Task TestAutoSaveOn_MultiContext_IndividualCommits() - { - _output.WriteLine("=== AUTOSAVE ON - INDIVIDUAL COMMITS TEST ==="); - _output.WriteLine("Goal: With AutoSave ON, each Add should commit independently (no atomicity)"); - _output.WriteLine(""); - - // Clear all data first - await _fixture.ClearAllPoliciesAsync(); - - // Create ONE shared connection - var sharedConnection = new NpgsqlConnection(_fixture.ConnectionString); - await sharedConnection.OpenAsync(); - - try - { - // Create three contexts using the SAME connection object - var options1 = new DbContextOptionsBuilder>() - .UseNpgsql(sharedConnection) - .Options; - var policyContext = new TestCasbinDbContext1(options1, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule"); - - var options2 = new DbContextOptionsBuilder>() - .UseNpgsql(sharedConnection) - .Options; - var groupingContext = new TestCasbinDbContext2(options2, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule"); - - var options3 = new DbContextOptionsBuilder>() - .UseNpgsql(sharedConnection) - .Options; - var roleContext = new TestCasbinDbContext3(options3, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule"); - - // Create provider and adapter - var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, sharedConnection); - var adapter = new EFCoreAdapter(provider); - - // Create enforcer with AutoSave ON (default) - var model = DefaultModel.CreateFromFile(ModelPath); - var enforcer = new Enforcer(model); - enforcer.SetAdapter(adapter); - // enforcer.EnableAutoSave(true); // Default is true, no need to set - _output.WriteLine("AutoSave enabled (default)"); - _output.WriteLine(""); - - // Add policies to context 1 and check DB immediately - _output.WriteLine("Step 1: Adding 2 p policies (should commit immediately):"); - enforcer.AddPolicy("alice", "data1", "read"); - enforcer.AddPolicy("bob", "data2", "write"); - var step1Count = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); - _output.WriteLine($" DB state after p policies: ({step1Count}, ?, ?)"); - Assert.Equal(2, step1Count); - _output.WriteLine(""); - - // Add policies to context 2 and check DB immediately - _output.WriteLine("Step 2: Adding 2 g policies (should commit immediately):"); - enforcer.AddGroupingPolicy("alice", "admin"); - enforcer.AddGroupingPolicy("bob", "user"); - var step2CountPolicy = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); - var step2CountGrouping = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); - _output.WriteLine($" DB state after g policies: ({step2CountPolicy}, {step2CountGrouping}, ?)"); - Assert.Equal(2, step2CountPolicy); - Assert.Equal(2, step2CountGrouping); - _output.WriteLine(""); - - // NOW: Drop the table for the third schema - _output.WriteLine("Step 3: Dropping casbin_roles.casbin_rule table..."); - await using (var cmd = sharedConnection.CreateCommand()) - { - cmd.CommandText = $"DROP TABLE {TransactionIntegrityTestFixture.RolesSchema}.casbin_rule"; - await cmd.ExecuteNonQueryAsync(); - } - _output.WriteLine("Table dropped!"); - _output.WriteLine(""); - - // Try to add policy to context 3 - should fail - _output.WriteLine("Step 4: Trying to add g2 policy (expecting exception):"); - var exception = await Assert.ThrowsAnyAsync(async () => - { - enforcer.AddNamedGroupingPolicy("g2", "admin", "role-superuser"); - }); - _output.WriteLine($"✓ Exception caught as expected: {exception.GetType().Name}"); - _output.WriteLine($" Message: {exception.Message}"); - _output.WriteLine(""); - - // Check final state - contexts 1 & 2 should still have their data - var finalPoliciesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); - var finalGroupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); - - _output.WriteLine("FINAL RESULTS:"); - _output.WriteLine($" casbin_policies: {finalPoliciesCount}"); - _output.WriteLine($" casbin_groupings: {finalGroupingsCount}"); - _output.WriteLine($" casbin_roles: N/A (table dropped)"); - _output.WriteLine(""); - - // ASSERT: With AutoSave ON, each context committed independently - if (finalPoliciesCount == 2 && finalGroupingsCount == 2) - { - _output.WriteLine("✓✓✓ AUTOSAVE ON INDIVIDUAL COMMITS TEST PASSED!"); - _output.WriteLine("Each AddPolicy committed independently, no cross-context atomicity"); - } - else - { - _output.WriteLine("✗✗✗ AUTOSAVE ON INDIVIDUAL COMMITS TEST FAILED!"); - _output.WriteLine($"Expected: (2, 2), Got: ({finalPoliciesCount}, {finalGroupingsCount})"); - } - - Assert.Equal(2, finalPoliciesCount); - Assert.Equal(2, finalGroupingsCount); - - await policyContext.DisposeAsync(); - await groupingContext.DisposeAsync(); - await roleContext.DisposeAsync(); - } - finally - { - await sharedConnection.DisposeAsync(); - } - } - - /// - /// Tests AutoSave OFF success path with batched commit across multiple contexts. - /// - /// Verifies that with AutoSave OFF, SavePolicy() batches all operations - /// in a shared transaction and commits atomically. - /// - /// This test now passes with Casbin.NET 2.19.1+ which fixed the AutoSave bug. - /// - [Fact] - public async Task TestAutoSaveOff_MultiContext_BatchedCommit() - { - _output.WriteLine("=== AUTOSAVE OFF - SUCCESS PATH TEST ==="); - _output.WriteLine("Goal: With AutoSave OFF, SavePolicy should batch all operations in shared transaction"); - _output.WriteLine(""); - - // Clear all data first - await _fixture.ClearAllPoliciesAsync(); - - // Create ONE shared connection - var sharedConnection = new NpgsqlConnection(_fixture.ConnectionString); - await sharedConnection.OpenAsync(); - - try - { - // Create three contexts - var options1 = new DbContextOptionsBuilder>() - .UseNpgsql(sharedConnection) - .Options; - var policyContext = new TestCasbinDbContext1(options1, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule"); - - var options2 = new DbContextOptionsBuilder>() - .UseNpgsql(sharedConnection) - .Options; - var groupingContext = new TestCasbinDbContext2(options2, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule"); - - var options3 = new DbContextOptionsBuilder>() - .UseNpgsql(sharedConnection) - .Options; - var roleContext = new TestCasbinDbContext3(options3, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule"); - - var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, sharedConnection); - var adapter = new EFCoreAdapter(provider); - - var model = DefaultModel.CreateFromFile(ModelPath); - var enforcer = new Enforcer(model); - enforcer.SetAdapter(adapter); - enforcer.EnableAutoSave(false); - _output.WriteLine("AutoSave disabled"); - _output.WriteLine(""); - - // Add policies - _output.WriteLine("Adding policies with AutoSave OFF:"); - enforcer.AddPolicy("alice", "data1", "read"); - enforcer.AddPolicy("bob", "data2", "write"); - enforcer.AddGroupingPolicy("alice", "admin"); - enforcer.AddGroupingPolicy("bob", "user"); - enforcer.AddNamedGroupingPolicy("g2", "admin", "role-superuser"); - enforcer.AddNamedGroupingPolicy("g2", "user", "role-basic"); - _output.WriteLine(" Added 6 policies total"); - _output.WriteLine(""); - - // Check DB before SavePolicy - var beforeCount1 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); - var beforeCount2 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); - var beforeCount3 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema); - _output.WriteLine($"DB state BEFORE SavePolicy: ({beforeCount1}, {beforeCount2}, {beforeCount3})"); - - if (beforeCount1 == 0 && beforeCount2 == 0 && beforeCount3 == 0) - { - _output.WriteLine("✓ Confirmed: Policies batched in memory, not committed yet"); - } - _output.WriteLine(""); - - // Call SavePolicy - _output.WriteLine("Calling SavePolicyAsync()..."); - await enforcer.SavePolicyAsync(); - _output.WriteLine("SavePolicyAsync() completed"); - _output.WriteLine(""); - - // Check final state - var finalCount1 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); - var finalCount2 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); - var finalCount3 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema); - - _output.WriteLine($"DB state AFTER SavePolicy: ({finalCount1}, {finalCount2}, {finalCount3})"); - _output.WriteLine(""); - - if (finalCount1 == 2 && finalCount2 == 2 && finalCount3 == 2) - { - _output.WriteLine("✓✓✓ AUTOSAVE OFF SUCCESS PATH TEST PASSED!"); - _output.WriteLine("SavePolicy committed all batched policies atomically"); - } - else - { - _output.WriteLine("✗✗✗ AUTOSAVE OFF SUCCESS PATH TEST FAILED!"); - _output.WriteLine($"Expected: (2, 2, 2), Got: ({finalCount1}, {finalCount2}, {finalCount3})"); - } - - Assert.Equal(2, finalCount1); - Assert.Equal(2, finalCount2); - Assert.Equal(2, finalCount3); - - await policyContext.DisposeAsync(); - await groupingContext.DisposeAsync(); - await roleContext.DisposeAsync(); - } - finally - { - await sharedConnection.DisposeAsync(); - } - } - - #endregion - - private static async Task InitPolicyAsync(CasbinDbContext context) - { - // Clear existing policies - use AsNoTracking to avoid concurrency exceptions - // when policies may have been deleted by fixture cleanup - var existing = await context.Policies.AsNoTracking().ToListAsync(); - if (existing.Any()) - { - context.Policies.AttachRange(existing); - context.Policies.RemoveRange(existing); - await context.SaveChangesAsync(); - } - - // Add test data - context.Policies.Add(new EFCorePersistPolicy() - { - Type = "p", - Value1 = "alice", - Value2 = "data1", - Value3 = "read", - }); - context.Policies.Add(new EFCorePersistPolicy() - { - Type = "p", - Value1 = "bob", - Value2 = "data2", - Value3 = "write", - }); - context.Policies.Add(new EFCorePersistPolicy() - { - Type = "p", - Value1 = "data2_admin", - Value2 = "data2", - Value3 = "read", - }); - context.Policies.Add(new EFCorePersistPolicy() - { - Type = "p", - Value1 = "data2_admin", - Value2 = "data2", - Value3 = "write", - }); - context.Policies.Add(new EFCorePersistPolicy() - { - Type = "g", - Value1 = "alice", - Value2 = "data2_admin", - }); - await context.SaveChangesAsync(); - } - } -} diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/SchemaDistributionTests.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/SchemaDistributionTests.cs deleted file mode 100644 index 29cacf2..0000000 --- a/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/SchemaDistributionTests.cs +++ /dev/null @@ -1,340 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Casbin; -using Casbin.Model; -using Npgsql; -using Microsoft.EntityFrameworkCore; -using Xunit; -using Xunit.Abstractions; - -namespace Casbin.Persist.Adapter.EFCore.UnitTest.Integration -{ - /// - /// Tests to verify whether HasDefaultSchema() properly distributes policies across PostgreSQL schemas, - /// both with separate connections and with shared connections. - /// - /// Purpose: Determine if explicit SET search_path is necessary or if EF Core's HasDefaultSchema() - /// generates schema-qualified SQL that works correctly with shared connections. - /// - [Trait("Category", "Integration")] - [Collection("IntegrationTests")] - public class SchemaDistributionTests : IClassFixture, IAsyncLifetime - { - private readonly TransactionIntegrityTestFixture _fixture; - private readonly ITestOutputHelper _output; - private const string ModelPath = "examples/multi_context_model.conf"; - - public SchemaDistributionTests(TransactionIntegrityTestFixture fixture, ITestOutputHelper output) - { - _fixture = fixture; - _output = output; - } - - public Task InitializeAsync() => _fixture.ClearAllPoliciesAsync(); - public Task DisposeAsync() => _fixture.RunMigrationsAsync(); - - #region Helper: Derived Context Classes - - /// - /// Derived context for policies schema - /// - public class TestCasbinDbContext1 : CasbinDbContext - { - public TestCasbinDbContext1( - DbContextOptions> options, - string schemaName, - string tableName) - : base(options, schemaName, tableName) - { - } - } - - /// - /// Derived context for groupings schema - /// - public class TestCasbinDbContext2 : CasbinDbContext - { - public TestCasbinDbContext2( - DbContextOptions> options, - string schemaName, - string tableName) - : base(options, schemaName, tableName) - { - } - } - - /// - /// Derived context for roles schema - /// - public class TestCasbinDbContext3 : CasbinDbContext - { - public TestCasbinDbContext3( - DbContextOptions> options, - string schemaName, - string tableName) - : base(options, schemaName, tableName) - { - } - } - - #endregion - - #region Helper: Three-Context Provider - - /// - /// Provider that routes policy types to three separate contexts - /// - private class ThreeWayContextProvider : ICasbinDbContextProvider - { - private readonly CasbinDbContext _policyContext; - private readonly CasbinDbContext _groupingContext; - private readonly CasbinDbContext _roleContext; - private readonly System.Data.Common.DbConnection? _sharedConnection; - - public ThreeWayContextProvider( - CasbinDbContext policyContext, - CasbinDbContext groupingContext, - CasbinDbContext roleContext, - System.Data.Common.DbConnection? sharedConnection) - { - _policyContext = policyContext; - _groupingContext = groupingContext; - _roleContext = roleContext; - _sharedConnection = sharedConnection; - } - - public DbContext GetContextForPolicyType(string policyType) - { - return policyType switch - { - "p" => _policyContext, // p policies → casbin_policies schema - "g" => _groupingContext, // g groupings → casbin_groupings schema - "g2" => _roleContext, // g2 roles → casbin_roles schema - _ => _policyContext - }; - } - - public IEnumerable GetAllContexts() - { - return new[] { _policyContext, _groupingContext, _roleContext }; - } - - public System.Data.Common.DbConnection? GetSharedConnection() - { - return _sharedConnection; - } - } - - #endregion - - #region Test 1: Separate Connections (Control/Baseline) - - /// - /// BASELINE TEST: Proves that HasDefaultSchema() correctly distributes policies across schemas - /// when contexts use SEPARATE connections (no shared connection). - /// - /// This is the baseline that should work regardless of any SET search_path logic. - /// - [Fact] - public async Task SavePolicy_SeparateConnections_ShouldDistributeAcrossSchemas() - { - _output.WriteLine("=== TEST: Separate Connections - Schema Distribution ==="); - - // Create three contexts with SEPARATE connection strings (no shared connection) - var policyOptions = new DbContextOptionsBuilder>() - .UseNpgsql(_fixture.ConnectionString) // Connection #1 - .Options; - var policyContext = new TestCasbinDbContext1(policyOptions, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule"); - - var groupingOptions = new DbContextOptionsBuilder>() - .UseNpgsql(_fixture.ConnectionString) // Connection #2 - .Options; - var groupingContext = new TestCasbinDbContext2(groupingOptions, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule"); - - var roleOptions = new DbContextOptionsBuilder>() - .UseNpgsql(_fixture.ConnectionString) // Connection #3 - .Options; - var roleContext = new TestCasbinDbContext3(roleOptions, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule"); - - _output.WriteLine("Created three contexts with SEPARATE connections"); - - // Verify they are different connection objects - var conn1 = policyContext.Database.GetDbConnection(); - var conn2 = groupingContext.Database.GetDbConnection(); - var conn3 = roleContext.Database.GetDbConnection(); - - Assert.False(ReferenceEquals(conn1, conn2), "Connections 1 and 2 should be different objects"); - Assert.False(ReferenceEquals(conn2, conn3), "Connections 2 and 3 should be different objects"); - _output.WriteLine("Verified: Contexts use DIFFERENT DbConnection objects"); - - try - { - // Create provider and adapter - // Pass null since these contexts use separate connections - var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, null); - var adapter = new EFCoreAdapter(provider); - - // Create enforcer without loading policy (tables might be empty) - var model = DefaultModel.CreateFromFile(ModelPath); - var enforcer = new Enforcer(model); - enforcer.SetAdapter(adapter); - - // Add policies to in-memory model (not persisted yet) - enforcer.AddPolicy("alice", "data1", "read"); // → casbin_policies - enforcer.AddGroupingPolicy("alice", "admin"); // → casbin_groupings - enforcer.AddNamedGroupingPolicy("g2", "admin", "role-superuser"); // → casbin_roles - - _output.WriteLine("Added policies to in-memory model:"); - _output.WriteLine(" p policy → casbin_policies"); - _output.WriteLine(" g policy → casbin_groupings"); - _output.WriteLine(" g2 policy → casbin_roles"); - - // Save to database - await enforcer.SavePolicyAsync(); - _output.WriteLine("Called SavePolicyAsync()"); - - // Verify distribution across schemas - var policiesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); - var groupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); - var rolesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema); - - _output.WriteLine($"Schema distribution:"); - _output.WriteLine($" casbin_policies: {policiesCount} policy"); - _output.WriteLine($" casbin_groupings: {groupingsCount} policy"); - _output.WriteLine($" casbin_roles: {rolesCount} policy"); - - // CRITICAL ASSERTION: Policies should be distributed across all three schemas - Assert.Equal(1, policiesCount); - Assert.Equal(1, groupingsCount); - Assert.Equal(1, rolesCount); - - _output.WriteLine("✓ BASELINE TEST PASSED: HasDefaultSchema() distributes policies correctly with separate connections"); - } - finally - { - await policyContext.DisposeAsync(); - await groupingContext.DisposeAsync(); - await roleContext.DisposeAsync(); - } - } - - #endregion - - #region Test 2: Shared Connection (Critical Test) - - /// - /// CRITICAL TEST: Determines if HasDefaultSchema() correctly distributes policies across schemas - /// when contexts share a SINGLE DbConnection object. - /// - /// If this test FAILS: SET search_path approach is necessary - /// If this test PASSES: SET search_path approach is NOT necessary - /// - [Fact] - public async Task SavePolicy_SharedConnection_ShouldDistributeAcrossSchemas() - { - _output.WriteLine("=== TEST: Shared Connection - Schema Distribution ==="); - - // Create ONE shared connection (CRITICAL for this test) - var sharedConnection = new NpgsqlConnection(_fixture.ConnectionString); - await sharedConnection.OpenAsync(); - _output.WriteLine("Opened shared connection for all three contexts"); - - try - { - // Create three contexts using SAME connection object - var policyOptions = new DbContextOptionsBuilder>() - .UseNpgsql(sharedConnection) // ✅ Shared connection - .Options; - var policyContext = new TestCasbinDbContext1(policyOptions, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule"); - - var groupingOptions = new DbContextOptionsBuilder>() - .UseNpgsql(sharedConnection) // ✅ Same connection - .Options; - var groupingContext = new TestCasbinDbContext2(groupingOptions, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule"); - - var roleOptions = new DbContextOptionsBuilder>() - .UseNpgsql(sharedConnection) // ✅ Same connection - .Options; - var roleContext = new TestCasbinDbContext3(roleOptions, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule"); - - _output.WriteLine("Created three contexts sharing the same connection"); - - // Verify reference equality - var conn1 = policyContext.Database.GetDbConnection(); - var conn2 = groupingContext.Database.GetDbConnection(); - var conn3 = roleContext.Database.GetDbConnection(); - - Assert.True(ReferenceEquals(conn1, conn2), "Connections 1 and 2 should be the SAME object"); - Assert.True(ReferenceEquals(conn2, conn3), "Connections 2 and 3 should be the SAME object"); - _output.WriteLine("Verified: All contexts share the SAME DbConnection object (reference equality)"); - - // Create provider and adapter - // Pass sharedConnection since all contexts share it - var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, sharedConnection); - var adapter = new EFCoreAdapter(provider); - - // Create enforcer without loading policy (tables might be empty) - var model = DefaultModel.CreateFromFile(ModelPath); - var enforcer = new Enforcer(model); - enforcer.SetAdapter(adapter); - - // Add policies to in-memory model (not persisted yet) - enforcer.AddPolicy("bob", "data2", "write"); // → casbin_policies - enforcer.AddGroupingPolicy("bob", "developer"); // → casbin_groupings - enforcer.AddNamedGroupingPolicy("g2", "developer", "role-contributor"); // → casbin_roles - - _output.WriteLine("Added policies to in-memory model:"); - _output.WriteLine(" p policy → should go to casbin_policies"); - _output.WriteLine(" g policy → should go to casbin_groupings"); - _output.WriteLine(" g2 policy → should go to casbin_roles"); - - // Save to database - await enforcer.SavePolicyAsync(); - _output.WriteLine("Called SavePolicyAsync()"); - - // Verify distribution across schemas - var policiesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema); - var groupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema); - var rolesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema); - - _output.WriteLine($"Schema distribution:"); - _output.WriteLine($" casbin_policies: {policiesCount} policy"); - _output.WriteLine($" casbin_groupings: {groupingsCount} policy"); - _output.WriteLine($" casbin_roles: {rolesCount} policy"); - - // CRITICAL ASSERTION: Policies should be distributed across all three schemas - // If all policies end up in ONE schema, HasDefaultSchema() does NOT work with shared connections - // and we NEED the SET search_path approach - - if (policiesCount == 1 && groupingsCount == 1 && rolesCount == 1) - { - _output.WriteLine("✓✓✓ SHARED CONNECTION TEST PASSED!"); - _output.WriteLine("HasDefaultSchema() correctly distributes policies even with shared connection"); - _output.WriteLine("CONCLUSION: SET search_path approach is NOT necessary"); - } - else - { - _output.WriteLine("✗✗✗ SHARED CONNECTION TEST FAILED!"); - _output.WriteLine($"Expected distribution: (1, 1, 1), Got: ({policiesCount}, {groupingsCount}, {rolesCount})"); - _output.WriteLine("CONCLUSION: SET search_path approach IS necessary for shared connections"); - } - - Assert.Equal(1, policiesCount); - Assert.Equal(1, groupingsCount); - Assert.Equal(1, rolesCount); - - await policyContext.DisposeAsync(); - await groupingContext.DisposeAsync(); - await roleContext.DisposeAsync(); - } - finally - { - await sharedConnection.DisposeAsync(); - } - } - - #endregion - } -} diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/TransactionIntegrityTests.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/TransactionIntegrityTests.cs deleted file mode 100644 index eff099f..0000000 --- a/Casbin.Persist.Adapter.EFCore.UnitTest/Integration/TransactionIntegrityTests.cs +++ /dev/null @@ -1,576 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Casbin; -using Casbin.Persist.Adapter.EFCore.Entities; -using Npgsql; -using Microsoft.EntityFrameworkCore; -using Xunit; - -namespace Casbin.Persist.Adapter.EFCore.UnitTest.Integration -{ - /// - /// Integration tests verifying transaction integrity guarantees for multi-context scenarios. - /// These tests prove that shared DbConnection objects enable atomic transactions across multiple contexts. - /// - /// IMPORTANT: These tests are excluded from CI/CD via the "Integration" trait. - /// Run locally with: dotnet test --filter "Category=Integration" - /// - [Trait("Category", "Integration")] - [Collection("IntegrationTests")] - public class TransactionIntegrityTests : IClassFixture, IAsyncLifetime - { - private readonly TransactionIntegrityTestFixture _fixture; - private const string ModelPath = "examples/multi_context_model.conf"; - - public TransactionIntegrityTests(TransactionIntegrityTestFixture fixture) - { - _fixture = fixture; - } - - public Task InitializeAsync() => _fixture.ClearAllPoliciesAsync(); - - public Task DisposeAsync() => _fixture.RunMigrationsAsync(); - - #region Helper: Derived Context Classes - - /// - /// Derived context for policies schema - /// - public class TestCasbinDbContext1 : CasbinDbContext - { - public TestCasbinDbContext1( - DbContextOptions> options, - string schemaName, - string tableName) - : base(options, schemaName, tableName) - { - } - } - - /// - /// Derived context for groupings schema - /// - public class TestCasbinDbContext2 : CasbinDbContext - { - public TestCasbinDbContext2( - DbContextOptions> options, - string schemaName, - string tableName) - : base(options, schemaName, tableName) - { - } - } - - /// - /// Derived context for roles schema - /// - public class TestCasbinDbContext3 : CasbinDbContext - { - public TestCasbinDbContext3( - DbContextOptions> options, - string schemaName, - string tableName) - : base(options, schemaName, tableName) - { - } - } - - #endregion - - #region Helper Methods - - /// - /// Creates a three-way context provider routing policies to different schemas: - /// - p, p2, p3... → policies schema - /// - g → groupings schema - /// - g2, g3, g4... → roles schema - /// - private class ThreeWayPolicyTypeProvider : ICasbinDbContextProvider - { - private readonly DbContext _policyContext; - private readonly DbContext _groupingContext; - private readonly DbContext _roleContext; - private readonly System.Data.Common.DbConnection? _sharedConnection; - - public ThreeWayPolicyTypeProvider( - DbContext policyContext, - DbContext groupingContext, - DbContext roleContext, - System.Data.Common.DbConnection? sharedConnection) - { - _policyContext = policyContext ?? throw new ArgumentNullException(nameof(policyContext)); - _groupingContext = groupingContext ?? throw new ArgumentNullException(nameof(groupingContext)); - _roleContext = roleContext ?? throw new ArgumentNullException(nameof(roleContext)); - _sharedConnection = sharedConnection; - } - - public DbContext GetContextForPolicyType(string policyType) - { - if (string.IsNullOrEmpty(policyType)) - return _policyContext; - - // Route p policies to policy context - if (policyType.StartsWith("p", StringComparison.OrdinalIgnoreCase)) - return _policyContext; - - // Route g2+ to role context - if (policyType.StartsWith("g2", StringComparison.OrdinalIgnoreCase) || - policyType.StartsWith("g3", StringComparison.OrdinalIgnoreCase) || - policyType.StartsWith("g4", StringComparison.OrdinalIgnoreCase)) - return _roleContext; - - // Route g to grouping context - return _groupingContext; - } - - public IEnumerable GetAllContexts() - { - return new[] { _policyContext, _groupingContext, _roleContext }; - } - - public System.Data.Common.DbConnection? GetSharedConnection() - { - return _sharedConnection; - } - } - - /// - /// Creates an enforcer with three contexts sharing the same DbConnection - /// - private async Task<(Enforcer enforcer, NpgsqlConnection connection)> CreateEnforcerWithSharedConnectionAsync() - { - // Create ONE shared connection - var connection = new NpgsqlConnection(_fixture.ConnectionString); - await connection.OpenAsync(); - - // Create three contexts sharing the same connection - var policyContext = CreateContext(connection, TransactionIntegrityTestFixture.PoliciesSchema); - var groupingContext = CreateContext(connection, TransactionIntegrityTestFixture.GroupingsSchema); - var roleContext = CreateContext(connection, TransactionIntegrityTestFixture.RolesSchema); - - // Create provider routing policy types to appropriate contexts - var provider = new ThreeWayPolicyTypeProvider(policyContext, groupingContext, roleContext, connection); - - // Create adapter and enforcer - var adapter = new EFCoreAdapter(provider); - var enforcer = new Enforcer(ModelPath, adapter); - - await enforcer.LoadPolicyAsync(); - - return (enforcer, connection); - } - - /// - /// Creates an enforcer with three contexts using SEPARATE DbConnections (same connection string) - /// This is used to demonstrate non-atomic behavior when connections are not shared. - /// - private async Task CreateEnforcerWithSeparateConnectionsAsync() - { - // Create THREE separate connections with same connection string - var policyConnection = new NpgsqlConnection(_fixture.ConnectionString); - var groupingConnection = new NpgsqlConnection(_fixture.ConnectionString); - var roleConnection = new NpgsqlConnection(_fixture.ConnectionString); - - await policyConnection.OpenAsync(); - await groupingConnection.OpenAsync(); - await roleConnection.OpenAsync(); - - // Create three contexts with different connection objects - var policyContext = CreateContext(policyConnection, TransactionIntegrityTestFixture.PoliciesSchema); - var groupingContext = CreateContext(groupingConnection, TransactionIntegrityTestFixture.GroupingsSchema); - var roleContext = CreateContext(roleConnection, TransactionIntegrityTestFixture.RolesSchema); - - // Create provider routing policy types to appropriate contexts - // Pass null for shared connection since these contexts use separate connections - var provider = new ThreeWayPolicyTypeProvider(policyContext, groupingContext, roleContext, null); - - // Create adapter and enforcer - var adapter = new EFCoreAdapter(provider); - var enforcer = new Enforcer(ModelPath, adapter); - - await enforcer.LoadPolicyAsync(); - - return enforcer; - } - - private CasbinDbContext CreateContext(NpgsqlConnection connection, string schemaName) - { - var options = new DbContextOptionsBuilder>() - .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", schemaName)) - .Options; - - // Return appropriate derived context based on schema name - if (schemaName == TransactionIntegrityTestFixture.PoliciesSchema) - return new TestCasbinDbContext1(options, schemaName, "casbin_rule"); - else if (schemaName == TransactionIntegrityTestFixture.GroupingsSchema) - return new TestCasbinDbContext2(options, schemaName, "casbin_rule"); - else if (schemaName == TransactionIntegrityTestFixture.RolesSchema) - return new TestCasbinDbContext3(options, schemaName, "casbin_rule"); - else - throw new ArgumentException($"Unknown schema name: {schemaName}", nameof(schemaName)); - } - - #endregion - - #region Test 1: Atomicity - Happy Path - - [Fact] - public async Task SavePolicy_WithSharedConnection_ShouldWriteToAllContextsAtomically() - { - // Arrange - var (enforcer, connection) = await CreateEnforcerWithSharedConnectionAsync(); - - try - { - // Add policies that will route to different contexts - await enforcer.AddPolicyAsync("alice", "data1", "read"); // → policies schema (p) - await enforcer.AddGroupingPolicyAsync("alice", "admin"); // → groupings schema (g) - await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser"); // → roles schema (g2) - - // Act - await enforcer.SavePolicyAsync(); - - // Assert - Verify each schema has exactly the expected policies - var policiesCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.PoliciesSchema, "p"); - var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.GroupingsSchema, "g"); - var rolesCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.RolesSchema, "g2"); - - Assert.Equal(1, policiesCount); - Assert.Equal(1, groupingsCount); - Assert.Equal(1, rolesCount); - } - finally - { - await connection.CloseAsync(); - await connection.DisposeAsync(); - } - } - - #endregion - - #region Test 2: Connection Sharing Verification - - [Fact] - public async Task MultiContextSetup_WithSharedConnection_ShouldShareSamePhysicalConnection() - { - // Arrange - var connection = new NpgsqlConnection(_fixture.ConnectionString); - await connection.OpenAsync(); - - try - { - var policyContext = CreateContext(connection, TransactionIntegrityTestFixture.PoliciesSchema); - var groupingContext = CreateContext(connection, TransactionIntegrityTestFixture.GroupingsSchema); - var roleContext = CreateContext(connection, TransactionIntegrityTestFixture.RolesSchema); - - // Act & Assert - Verify reference equality (not just connection string equality) - var policyConn = policyContext.Database.GetDbConnection(); - var groupingConn = groupingContext.Database.GetDbConnection(); - var roleConn = roleContext.Database.GetDbConnection(); - - Assert.Same(connection, policyConn); - Assert.Same(connection, groupingConn); - Assert.Same(connection, roleConn); - Assert.Same(policyConn, groupingConn); - Assert.Same(groupingConn, roleConn); - } - finally - { - await connection.CloseAsync(); - await connection.DisposeAsync(); - } - } - - #endregion - - #region Test 3: Rollback - Missing Table (CRITICAL TEST) - - [Fact] - public async Task SavePolicy_WhenTableDroppedInOneContext_ShouldRollbackAllContexts() - { - // Arrange - var (enforcer, connection) = await CreateEnforcerWithSharedConnectionAsync(); - - try - { - // Disable AutoSave so policies stay in-memory until SavePolicyAsync() is called - enforcer.EnableAutoSave(false); - - // Add policies to all contexts (in memory only, AutoSave is OFF) - await enforcer.AddPolicyAsync("alice", "data1", "read"); // → policies schema - await enforcer.AddGroupingPolicyAsync("alice", "admin"); // → groupings schema - await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser"); // → roles schema - - // Drop table in roles schema AFTER policies are in memory but BEFORE SavePolicy - // This simulates a catastrophic failure scenario where database schema is inconsistent - await _fixture.DropTableAsync(TransactionIntegrityTestFixture.RolesSchema); - - Exception caughtException = null; - - // Act - Try to save, should throw due to missing table in roles schema - try - { - await enforcer.SavePolicyAsync(); - } - catch (Exception ex) - { - caughtException = ex; - } - - // Assert - Verify exception was thrown - Assert.NotNull(caughtException); - - // Recreate table for verification queries - await _fixture.RunMigrationsAsync(); - - // CRITICAL ASSERTION - Verify ZERO policies in all contexts (rollback successful) - // This proves that when one context fails, ALL contexts roll back atomically - var policiesCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.PoliciesSchema, "p"); - var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.GroupingsSchema, "g"); - var rolesCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.RolesSchema, "g2"); - - // Verify atomicity: All contexts rolled back (no partial commits) - Assert.Equal(0, policiesCount); // Should be 0 (rolled back) - Assert.Equal(0, groupingsCount); // Should be 0 (rolled back) - Assert.Equal(0, rolesCount); // Should be 0 (rolled back) - - // If we got here, atomicity is PROVEN! - } - finally - { - await connection.CloseAsync(); - await connection.DisposeAsync(); - - // Restore table for subsequent tests - await _fixture.RunMigrationsAsync(); - } - } - - #endregion - - #region Test 4: Rollback - Missing Table - - [Fact] - public async Task SavePolicy_WhenTableMissingInOneContext_ShouldRollbackAllContexts() - { - // Arrange - var (enforcer, connection) = await CreateEnforcerWithSharedConnectionAsync(); - - try - { - // Disable AutoSave so policies stay in-memory until SavePolicyAsync() is called - enforcer.EnableAutoSave(false); - - // Add policies to all contexts - await enforcer.AddPolicyAsync("alice", "data1", "read"); - await enforcer.AddGroupingPolicyAsync("alice", "admin"); - await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser"); - - // Drop table in roles schema AFTER enforcer is created - await _fixture.DropTableAsync(TransactionIntegrityTestFixture.RolesSchema); - - Exception caughtException = null; - - // Act - Try to save, should throw due to missing table - try - { - await enforcer.SavePolicyAsync(); - } - catch (Exception ex) - { - caughtException = ex; - } - - // Assert - Verify exception was thrown - Assert.NotNull(caughtException); - - // Recreate table for verification queries - await _fixture.RunMigrationsAsync(); - - // CRITICAL ASSERTION - Verify ZERO policies in all contexts (rollback successful) - var policiesCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.PoliciesSchema, "p"); - var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.GroupingsSchema, "g"); - var rolesCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.RolesSchema, "g2"); - - Assert.Equal(0, policiesCount); - Assert.Equal(0, groupingsCount); - Assert.Equal(0, rolesCount); - } - finally - { - await connection.CloseAsync(); - await connection.DisposeAsync(); - - // Restore table for subsequent tests - await _fixture.RunMigrationsAsync(); - } - } - - #endregion - - #region Test 5: Consistency Verification - - [Fact] - public async Task MultipleSaveOperations_WithSharedConnection_ShouldMaintainDataConsistency() - { - // Arrange - var (enforcer, connection) = await CreateEnforcerWithSharedConnectionAsync(); - - try - { - // Act - Perform multiple incremental saves - // Save 1 - await enforcer.AddPolicyAsync("alice", "data1", "read"); - await enforcer.AddGroupingPolicyAsync("alice", "admin"); - await enforcer.SavePolicyAsync(); - - // Save 2 - await enforcer.AddPolicyAsync("bob", "data2", "write"); - await enforcer.AddGroupingPolicyAsync("bob", "user"); - await enforcer.SavePolicyAsync(); - - // Save 3 - await enforcer.AddPolicyAsync("charlie", "data3", "read"); - await enforcer.AddGroupingPolicyAsync("charlie", "user"); - await enforcer.SavePolicyAsync(); - - // Assert - Verify all 6 policies present (3 p policies, 3 g policies) - var policiesCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.PoliciesSchema, "p"); - var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.GroupingsSchema, "g"); - - Assert.Equal(3, policiesCount); - Assert.Equal(3, groupingsCount); - - // Verify all policies are enforced correctly - Assert.True(await enforcer.EnforceAsync("alice", "data1", "read")); - Assert.True(await enforcer.EnforceAsync("bob", "data2", "write")); - Assert.True(await enforcer.EnforceAsync("charlie", "data3", "read")); - } - finally - { - await connection.CloseAsync(); - await connection.DisposeAsync(); - } - } - - #endregion - - #region Test 6: Non-Atomic Behavior Without Shared Connection - - [Fact] - public async Task SavePolicy_WithSeparateConnections_ShouldNotBeAtomic() - { - // Arrange - Create enforcer with SEPARATE connection objects - var enforcer = await CreateEnforcerWithSeparateConnectionsAsync(); - - // Add policies to all contexts - await enforcer.AddPolicyAsync("alice", "data1", "read"); - await enforcer.AddGroupingPolicyAsync("alice", "admin"); - await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser"); - - // Drop table in roles schema to force failure - await _fixture.DropTableAsync(TransactionIntegrityTestFixture.RolesSchema); - - Exception caughtException = null; - - // Act - Try to save, should throw due to missing table in roles schema - try - { - await enforcer.SavePolicyAsync(); - } - catch (Exception ex) - { - caughtException = ex; - } - - // Assert - Verify exception was thrown - Assert.NotNull(caughtException); - - // Recreate table for verification queries - await _fixture.RunMigrationsAsync(); - - // CRITICAL ASSERTION - Verify policies WERE written to functioning contexts (NOT atomic!) - var policiesCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.PoliciesSchema, "p"); - var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.GroupingsSchema, "g"); - var rolesCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.RolesSchema, "g2"); - - // This test DOCUMENTS the non-atomic behavior without shared connections - // Policies and groupings were committed despite roles context failure - Assert.Equal(1, policiesCount); // Written (NOT rolled back) - Assert.Equal(1, groupingsCount); // Written (NOT rolled back) - Assert.Equal(0, rolesCount); // Failed to write (table dropped) - - // This proves that connection string matching alone is INSUFFICIENT for atomicity - // Must use shared DbConnection OBJECT for atomic transactions - } - - #endregion - - #region Test 7: Casbin In-Memory vs Database State - - [Fact] - public async Task SavePolicy_ShouldReflectDatabaseStateNotCasbinMemory() - { - // Arrange - Create first enforcer and save policies - var (enforcer1, connection1) = await CreateEnforcerWithSharedConnectionAsync(); - - try - { - await enforcer1.AddPolicyAsync("alice", "data1", "read"); - await enforcer1.AddGroupingPolicyAsync("alice", "admin"); - await enforcer1.SavePolicyAsync(); - } - finally - { - await connection1.CloseAsync(); - await connection1.DisposeAsync(); - } - - // Create second enforcer - loads existing policies from database - var (enforcer2, connection2) = await CreateEnforcerWithSharedConnectionAsync(); - - try - { - // Act - Try to add same policies again - var addedPolicy = await enforcer2.AddPolicyAsync("alice", "data1", "read"); - var addedGrouping = await enforcer2.AddGroupingPolicyAsync("alice", "admin"); - - // Assert - Casbin's in-memory check should prevent duplicates - Assert.False(addedPolicy); - Assert.False(addedGrouping); - - // Verify database unchanged (validates tests check database, not just Casbin memory) - var policiesCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.PoliciesSchema, "p"); - var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.GroupingsSchema, "g"); - - Assert.Equal(1, policiesCount); - Assert.Equal(1, groupingsCount); - } - finally - { - await connection2.CloseAsync(); - await connection2.DisposeAsync(); - } - } - - #endregion - } -} diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/xunit.runner.json b/Casbin.Persist.Adapter.EFCore.UnitTest/xunit.runner.json new file mode 100644 index 0000000..6b2d9df --- /dev/null +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeTestCollections": true, + "maxParallelThreads": -1 +} diff --git a/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs b/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs index 984830a..228cf30 100644 --- a/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs +++ b/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs @@ -105,11 +105,16 @@ public EFCoreAdapter(TDbContext context) public EFCoreAdapter(IServiceProvider serviceProvider) { if (serviceProvider == null) throw new ArgumentNullException(nameof(serviceProvider)); - _contextProvider = new ServiceProviderContextProvider(serviceProvider); - _persistPoliciesByContext = new Dictionary<(DbContext context, string policyType), DbSet>(); - // Eagerly resolve to set DbContext property for backward compatibility - DbContext = (TDbContext)_contextProvider.GetAllContexts().First(); + // Resolve DbContext from service provider + var context = serviceProvider.GetService(typeof(TDbContext)) as TDbContext + ?? throw new InvalidOperationException( + $"Unable to resolve service for type '{typeof(TDbContext)}' from IServiceProvider. " + + $"Ensure the DbContext is registered in the service collection."); + + DbContext = context; + _contextProvider = new SingleContextProvider(context); + _persistPoliciesByContext = new Dictionary<(DbContext context, string policyType), DbSet>(); } /// diff --git a/Casbin.Persist.Adapter.EFCore/ServiceProviderContextProvider.cs b/Casbin.Persist.Adapter.EFCore/ServiceProviderContextProvider.cs deleted file mode 100644 index 3d9b2c8..0000000 --- a/Casbin.Persist.Adapter.EFCore/ServiceProviderContextProvider.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data.Common; -using Microsoft.EntityFrameworkCore; - -namespace Casbin.Persist.Adapter.EFCore -{ - /// - /// Context provider that resolves a single DbContext from IServiceProvider (for DI scenarios). - /// - /// The type of the primary key - /// The type of DbContext to resolve - internal class ServiceProviderContextProvider : ICasbinDbContextProvider - where TKey : IEquatable - where TDbContext : DbContext - { - private readonly IServiceProvider _serviceProvider; - private TDbContext _cachedContext; - - public ServiceProviderContextProvider(IServiceProvider serviceProvider) - { - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - } - - public DbContext GetContextForPolicyType(string policyType) - { - return GetOrResolveContext(); - } - - public IEnumerable GetAllContexts() - { - yield return GetOrResolveContext(); - } - - public DbConnection GetSharedConnection() - { - // Single context - return its connection for shared transaction support - return GetOrResolveContext().Database.GetDbConnection(); - } - - private TDbContext GetOrResolveContext() - { - if (_cachedContext != null) - { - return _cachedContext; - } - - _cachedContext = _serviceProvider.GetService(typeof(TDbContext)) as TDbContext - ?? throw new InvalidOperationException($"Unable to resolve service for type '{typeof(TDbContext)}' from IServiceProvider."); - - return _cachedContext; - } - } -} diff --git a/EFCore-Adapter.sln b/EFCore-Adapter.sln index b4cb941..9148f0b 100644 --- a/EFCore-Adapter.sln +++ b/EFCore-Adapter.sln @@ -17,6 +17,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .releaserc.json = .releaserc.json EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Casbin.Persist.Adapter.EFCore.IntegrationTest", "Casbin.Persist.Adapter.EFCore.IntegrationTest\Casbin.Persist.Adapter.EFCore.IntegrationTest.csproj", "{3D148107-651A-492F-BF76-C417FA37B368}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -51,6 +53,18 @@ Global {0687B869-B179-4C4F-8419-B7D9B7C2DB5C}.Release|x64.Build.0 = Release|Any CPU {0687B869-B179-4C4F-8419-B7D9B7C2DB5C}.Release|x86.ActiveCfg = Release|Any CPU {0687B869-B179-4C4F-8419-B7D9B7C2DB5C}.Release|x86.Build.0 = Release|Any CPU + {3D148107-651A-492F-BF76-C417FA37B368}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3D148107-651A-492F-BF76-C417FA37B368}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3D148107-651A-492F-BF76-C417FA37B368}.Debug|x64.ActiveCfg = Debug|Any CPU + {3D148107-651A-492F-BF76-C417FA37B368}.Debug|x64.Build.0 = Debug|Any CPU + {3D148107-651A-492F-BF76-C417FA37B368}.Debug|x86.ActiveCfg = Debug|Any CPU + {3D148107-651A-492F-BF76-C417FA37B368}.Debug|x86.Build.0 = Debug|Any CPU + {3D148107-651A-492F-BF76-C417FA37B368}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3D148107-651A-492F-BF76-C417FA37B368}.Release|Any CPU.Build.0 = Release|Any CPU + {3D148107-651A-492F-BF76-C417FA37B368}.Release|x64.ActiveCfg = Release|Any CPU + {3D148107-651A-492F-BF76-C417FA37B368}.Release|x64.Build.0 = Release|Any CPU + {3D148107-651A-492F-BF76-C417FA37B368}.Release|x86.ActiveCfg = Release|Any CPU + {3D148107-651A-492F-BF76-C417FA37B368}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 7bd2780ced86b4ab055b767a0762722f0a213d4e Mon Sep 17 00:00:00 2001 From: Thor Arne Johansen Date: Thu, 13 Nov 2025 14:00:49 +0100 Subject: [PATCH 06/11] chore: Remove diagnostic logging and update integration test documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove all diagnostic Console.WriteLine statements from production adapter code and test fixtures. Update integration test README to reflect actual tests and remove historical/evolution content. Update PostgreSQL credentials to postgres4all!. Changes: - Remove ~65 diagnostic Console.WriteLine from EFCoreAdapter.cs - Remove ~10 diagnostic Console.WriteLine from TransactionIntegrityTestFixture.cs - Fix unused exception variable warnings (catch Exception ex -> catch) - Update PostgreSQL password to postgres4all! in code and documentation - Remove "Bug History" section from integration test README - Fix test counts (11→10 AutoSave tests, 20→19 total tests) - Remove non-existent test: SavePolicy_WhenDuplicateKeyViolationInOneContext_ShouldRollbackAllContexts - Fix SchemaDistributionTests test names to match actual code - Remove non-existent AutoSave test references - Fix example commands with correct test names - Remove "Test Evolution" and "Duplicate Key Violation Test" sections --- .../Integration/README.md | 69 ++---------------- .../TransactionIntegrityTestFixture.cs | 13 +--- .../EFCoreAdapter.cs | 72 +------------------ MULTI_CONTEXT_DESIGN.md | 2 +- 4 files changed, 12 insertions(+), 144 deletions(-) diff --git a/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/README.md b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/README.md index 8b8b975..2e2fdb1 100644 --- a/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/README.md +++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/README.md @@ -16,41 +16,6 @@ These integration tests are in a **separate test project** (`Casbin.Persist.Adap - Shares single PostgreSQL database: `casbin_integration_test` - Uses `DisableParallelization = true` on test collection for within-framework sequencing -## ✅ Casbin.NET AutoSave Bug - FIXED in v2.19.1 - -### Test Status Summary - -| Test | Status | Description | -|------|--------|-------------| -| **AutoSaveTests.TestAutoSaveOn_MultiContext_IndividualCommits** | ✅ PASSING | Documents non-atomic behavior with AutoSave ON (expected) | -| **AutoSaveTests.TestAutoSaveOff_MultiContext_RollbackOnFailure** | ✅ PASSING | Verifies atomic rollback with AutoSave OFF | -| **AutoSaveTests.TestAutoSaveOff_MultiContext_BatchedCommit** | ✅ PASSING | Verifies batched commit with AutoSave OFF | -| **AutoSaveTests.TestGroupingPolicyAutoSaveOff** | ✅ PASSING | Single-context sync version | -| **AutoSaveTests.TestGroupingPolicyAutoSaveOffAsync** | ✅ PASSING | Single-context async version | - -### Bug History (RESOLVED) - -Casbin.NET had a bug where `AddGroupingPolicy()` and `AddNamedGroupingPolicy()` ignored the `EnableAutoSave(false)` setting. This was fixed in **Casbin.NET v2.19.1**. - -**Previous behavior (bug):** -- ✅ `AddPolicy()` respected AutoSave OFF (didn't call adapter) -- ❌ `AddGroupingPolicy()` ignored AutoSave OFF (called adapter immediately) -- ❌ `AddNamedGroupingPolicy()` ignored AutoSave OFF (called adapter immediately) - -**Current behavior (fixed in v2.19.1):** -- ✅ All policy methods now respect the `EnableAutoSave(false)` setting -- ✅ Policies stay in memory until `SavePolicy()` is called -- ✅ Atomic transactions work correctly with AutoSave OFF - -### Diagnostic Logging (Can Be Removed) - -The adapter code may still include diagnostic Console.WriteLine statements that were used to debug the bug. These can now be removed as the issue is resolved: -- Shows when adapter methods are called -- Shows call stacks proving Casbin.NET is calling the adapter -- Shows SaveChanges() being invoked despite AutoSave OFF - ---- - ## Purpose These tests prove that when multiple `DbContext` instances share the same `DbConnection` object, operations across contexts are **atomic** - they either all succeed or all fail together. @@ -93,7 +58,7 @@ The tests use these default credentials: - **Host**: `localhost:5432` - **Database**: `casbin_integration_test` - **Username**: `postgres` -- **Password**: `postgres` +- **Password**: `postgres4all!` **If your PostgreSQL uses different credentials**, update the connection string in [TransactionIntegrityTestFixture.cs](TransactionIntegrityTestFixture.cs): @@ -138,10 +103,10 @@ The integration tests are organized into 3 test classes: | Test Class | Tests | Purpose | |------------|-------|---------| | `TransactionIntegrityTests` | 7 | Multi-context transaction atomicity and rollback | -| `AutoSaveTests` | 11 | Casbin.NET AutoSave behavior verification | +| `AutoSaveTests` | 10 | Casbin.NET AutoSave behavior verification | | `SchemaDistributionTests` | 2 | Schema routing with shared connections | -**Total:** 20 integration tests +**Total:** 19 integration tests The tests use a three-way context provider that routes: - **p policies** → `casbin_policies` schema @@ -156,7 +121,6 @@ This simulates real-world multi-context scenarios where different policy types a |------|----------------| | `SavePolicy_WithSharedConnection_ShouldWriteToAllContextsAtomically` | Policies written to 3 schemas in a single atomic transaction | | `MultiContextSetup_WithSharedConnection_ShouldShareSamePhysicalConnection` | Reference equality confirms DbConnection object sharing | -| `SavePolicy_WhenDuplicateKeyViolationInOneContext_ShouldRollbackAllContexts` | **CRITICAL**: Failure in one context rolls back ALL contexts | | `SavePolicy_WhenTableMissingInOneContext_ShouldRollbackAllContexts` | Severe failures cause complete rollback | | `MultipleSaveOperations_WithSharedConnection_ShouldMaintainDataConsistency` | Multiple operations maintain consistency over time | | `SavePolicy_WithSeparateConnections_ShouldNotBeAtomic` | **Negative test**: Proves separate connections are NOT atomic | @@ -176,8 +140,8 @@ These tests verify that `CasbinDbContext.HasDefaultSchema()` correctly routes po | Test | Purpose | Status | |------|---------|--------| -| `BaselineSchemaDistributionTest` | Baseline behavior with separate connections | ✅ Passing | -| `SchemaDistributionTest_WithSharedConnection` | Schema routing with shared connection | ✅ Passing | +| `SavePolicy_SeparateConnections_ShouldDistributeAcrossSchemas` | Baseline behavior with separate connections | ✅ Passing | +| `SavePolicy_SharedConnection_ShouldDistributeAcrossSchemas` | Schema routing with shared connection | ✅ Passing | **What They Test:** @@ -207,13 +171,13 @@ When using a shared connection for atomic transactions, each context must still dotnet test -f net6.0 --filter "FullyQualifiedName~SchemaDistributionTests" --verbosity normal # Run specific test -dotnet test -f net6.0 --filter "FullyQualifiedName~SchemaDistributionTest_WithSharedConnection" --verbosity normal +dotnet test -f net6.0 --filter "FullyQualifiedName~SavePolicy_SharedConnection_ShouldDistributeAcrossSchemas" --verbosity normal ``` ### AutoSaveTests **File:** [AutoSaveTests.cs](AutoSaveTests.cs) -**Test Count:** 11 +**Test Count:** 10 **Status:** ✅ All Passing **Purpose:** @@ -230,8 +194,6 @@ These tests verify the Casbin Enforcer's `EnableAutoSave` behavior in multi-cont | `TestGroupingPolicyAutoSaveOff` | Grouping policies defer with AutoSave OFF | ✅ Passing | | `TestAutoSaveOn_MultiContext_IndividualCommits` | Multi-context: operations commit independently | ✅ Passing | | `TestAutoSaveOff_MultiContext_RollbackOnFailure` | Multi-context: atomic rollback with AutoSave OFF | ✅ Passing | -| `TestRemovePolicyAutoSaveOn` / `Off` | Remove operations respect AutoSave | ✅ Passing | -| `TestUpdatePolicyAutoSaveOn` | Update operations respect AutoSave | ✅ Passing | **Why AutoSave Testing Matters:** @@ -295,25 +257,8 @@ These tests call `enforcer.EnableAutoSave(false)` immediately after creating the - **With AutoSave OFF:** Policies stay in-memory until `SavePolicyAsync()` is called. When the transaction fails, ALL operations (INSERT and DELETE) roll back atomically. -**Test Evolution:** - -These tests originally failed with "Expected: 0, Actual: 1-2" because they used AutoSave ON. Adding `EnableAutoSave(false)` fixed them by ensuring proper rollback verification. - **Code Reference:** See lines 302, 370 in [TransactionIntegrityTests.cs](TransactionIntegrityTests.cs) -### Duplicate Key Violation Test - -The test `SavePolicy_WhenDuplicateKeyViolationInOneContext_ShouldRollbackAllContexts` verifies rollback on constraint violations. - -This test: -1. Inserts a policy directly into the `casbin_roles` schema -2. Adds NEW policies to `casbin_policies` and `casbin_groupings` schemas -3. Manipulates Casbin's in-memory model to create a duplicate in the `casbin_roles` schema -4. Calls `SavePolicyAsync()` which should fail due to the duplicate -5. **Verifies that NO policies were written** to ANY schema (complete rollback) - -This proves that the shared transaction mechanism works correctly - when one context fails, all contexts roll back atomically. - ## See Also - [MULTI_CONTEXT_DESIGN.md](../../MULTI_CONTEXT_DESIGN.md) - Technical design and architecture diff --git a/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTestFixture.cs b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTestFixture.cs index 142c51f..f1356d8 100644 --- a/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTestFixture.cs +++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTestFixture.cs @@ -12,7 +12,7 @@ namespace Casbin.Persist.Adapter.EFCore.UnitTest.Integration /// Prerequisites: /// - PostgreSQL running on localhost:5432 /// - Database "casbin_integration_test" must exist - /// - Default credentials: postgres/postgres (or update ConnectionString) + /// - Default credentials: postgres/postgres4all! (or update ConnectionString) /// public class TransactionIntegrityTestFixture : IAsyncLifetime { @@ -28,30 +28,21 @@ public TransactionIntegrityTestFixture() { // Use local PostgreSQL for integration tests // Database must exist before running tests - ConnectionString = "Host=localhost;Database=casbin_integration_test;Username=synapse;Password=synapsepwd"; - Console.WriteLine("[FIXTURE] Constructor called"); + ConnectionString = "Host=localhost;Database=casbin_integration_test;Username=postgres;Password=postgres4all!"; } public async Task InitializeAsync() { - Console.WriteLine("[FIXTURE] InitializeAsync START"); try { // Create schemas - Console.WriteLine("[FIXTURE] Creating schemas..."); await CreateSchemasAsync(); - Console.WriteLine("[FIXTURE] Schemas created"); // Run migrations for all three schemas - Console.WriteLine("[FIXTURE] Running migrations..."); await RunMigrationsAsync(); - Console.WriteLine("[FIXTURE] Migrations complete - tables should now exist with v0-v13"); } catch (Exception ex) { - Console.WriteLine($"[FIXTURE INITIALIZATION FAILED]"); - Console.WriteLine($"Error: {ex.Message}"); - Console.WriteLine($"Stack trace: {ex.StackTrace}"); throw new InvalidOperationException( $"Failed to initialize TransactionIntegrityTestFixture. " + $"Ensure PostgreSQL is running and database 'casbin_integration_test' exists. " + diff --git a/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs b/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs index 228cf30..587a1f8 100644 --- a/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs +++ b/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs @@ -403,21 +403,16 @@ public virtual async Task SavePolicyAsync(IPolicyStore store) var contexts = _contextProvider.GetAllContexts().Distinct().ToList(); - Console.WriteLine($"[DIAGNOSTIC] SavePolicyAsync: {contexts.Count} contexts, {persistPolicies.Count} policies"); - // Check if we can use a shared transaction (all contexts use same connection) bool canShareTransaction = CanShareTransaction(contexts); - Console.WriteLine($"[DIAGNOSTIC] CanShareTransaction returned: {canShareTransaction}"); if (contexts.Count == 1 || canShareTransaction) { - Console.WriteLine($"[DIAGNOSTIC] Using SHARED transaction path"); // Single context or shared connection - use single transaction await SavePolicyWithSharedTransactionAsync(store, contexts, policiesByContext); } else { - Console.WriteLine($"[DIAGNOSTIC] Using INDIVIDUAL transactions path"); // Multiple separate databases - use individual transactions per context await SavePolicyWithIndividualTransactionsAsync(store, contexts, policiesByContext); } @@ -431,69 +426,52 @@ private async Task SavePolicyWithSharedTransactionAsync(IPolicyStore store, List if (sharedConnection != null) { // Use connection-level transaction (required for PostgreSQL savepoint handling) - Console.WriteLine($"[DIAGNOSTIC] SavePolicyWithSharedTransactionAsync: Using connection-level transaction for {contexts.Count} contexts"); - if (sharedConnection.State != System.Data.ConnectionState.Open) { await sharedConnection.OpenAsync(); } await using var transaction = await sharedConnection.BeginTransactionAsync(); - Console.WriteLine($"[DIAGNOSTIC] Connection-level transaction started: {transaction.GetType().Name}"); try { // Enlist all contexts in the connection-level transaction - Console.WriteLine($"[DIAGNOSTIC] Enlisting all {contexts.Count} contexts in connection-level transaction"); foreach (var context in contexts) { context.Database.UseTransaction(transaction); - Console.WriteLine($"[DIAGNOSTIC] Enlisted: {context.GetType().Name}"); } // Clear existing policies from all contexts - Console.WriteLine($"[DIAGNOSTIC] Phase 1: Deleting existing policies from {contexts.Count} contexts"); for (int i = 0; i < contexts.Count; i++) { var context = contexts[i]; - Console.WriteLine($"[DIAGNOSTIC] Processing context {i + 1}/{contexts.Count}: {context.GetType().Name}"); var dbSet = GetCasbinRuleDbSet(context, null); - Console.WriteLine($"[DIAGNOSTIC] Executing delete on DbSet..."); #if NET7_0_OR_GREATER // EF Core 7+: Use ExecuteDeleteAsync for better performance (set-based delete without loading entities) await dbSet.ExecuteDeleteAsync(); - Console.WriteLine($"[DIAGNOSTIC] ExecuteDeleteAsync completed"); #else // EF Core 3.1-6.0: Fall back to traditional approach var existingRules = await dbSet.ToListAsync(); dbSet.RemoveRange(existingRules); await context.SaveChangesAsync(); - Console.WriteLine($"[DIAGNOSTIC] SaveChangesAsync completed after RemoveRange"); #endif } - Console.WriteLine($"[DIAGNOSTIC] Phase 2: Adding new policies to {policiesByContext.Count} contexts"); // Add new policies to respective contexts for (int i = 0; i < policiesByContext.Count; i++) { var group = policiesByContext[i]; var context = group.Key; - Console.WriteLine($"[DIAGNOSTIC] Adding policies {i + 1}/{policiesByContext.Count} to context: {context.GetType().Name}"); var dbSet = GetCasbinRuleDbSet(context, null); var saveRules = OnSavePolicy(store, group); - Console.WriteLine($"[DIAGNOSTIC] Adding {saveRules.Count()} policies to DbSet"); await dbSet.AddRangeAsync(saveRules); - Console.WriteLine($"[DIAGNOSTIC] Calling SaveChangesAsync..."); await context.SaveChangesAsync(); - Console.WriteLine($"[DIAGNOSTIC] SaveChangesAsync completed"); } - Console.WriteLine($"[DIAGNOSTIC] Committing connection-level transaction..."); await transaction.CommitAsync(); - Console.WriteLine($"[DIAGNOSTIC] Transaction committed successfully"); // Clear transaction state from all contexts to prevent SAVEPOINT errors // in subsequent SaveChanges() calls @@ -501,21 +479,16 @@ private async Task SavePolicyWithSharedTransactionAsync(IPolicyStore store, List { context.Database.UseTransaction(null); } - Console.WriteLine($"[DIAGNOSTIC] Cleared transaction state from all contexts"); } - catch (Exception ex) + catch { - Console.WriteLine($"[DIAGNOSTIC] EXCEPTION CAUGHT: {ex.GetType().Name}: {ex.Message}"); - Console.WriteLine($"[DIAGNOSTIC] Rolling back connection-level transaction..."); await transaction.RollbackAsync(); - Console.WriteLine($"[DIAGNOSTIC] Transaction rolled back"); // Clear transaction state from all contexts foreach (var context in contexts) { context.Database.UseTransaction(null); } - Console.WriteLine($"[DIAGNOSTIC] Cleared transaction state from all contexts after rollback"); throw; } } @@ -523,90 +496,65 @@ private async Task SavePolicyWithSharedTransactionAsync(IPolicyStore store, List { // Fall back to context-level transaction var primaryContext = contexts.First(); - Console.WriteLine($"[DIAGNOSTIC] SavePolicyWithSharedTransactionAsync: Using context-level transaction for {contexts.Count} contexts"); - Console.WriteLine($"[DIAGNOSTIC] Primary context: {primaryContext.GetType().Name}"); await using var transaction = await primaryContext.Database.BeginTransactionAsync(); - Console.WriteLine($"[DIAGNOSTIC] Transaction started: {transaction.TransactionId}"); try { // Clear existing policies from all contexts - Console.WriteLine($"[DIAGNOSTIC] Phase 1: Deleting existing policies from {contexts.Count} contexts"); for (int i = 0; i < contexts.Count; i++) { var context = contexts[i]; - Console.WriteLine($"[DIAGNOSTIC] Processing context {i + 1}/{contexts.Count}: {context.GetType().Name}"); if (context != primaryContext) { var dbTransaction = transaction.GetDbTransaction(); - Console.WriteLine($"[DIAGNOSTIC] Enlisting context in transaction via UseTransaction()"); // Use synchronous UseTransaction since we're just enlisting in an existing transaction context.Database.UseTransaction(dbTransaction); } - else - { - Console.WriteLine($"[DIAGNOSTIC] This is the primary context (already in transaction)"); - } var dbSet = GetCasbinRuleDbSet(context, null); - Console.WriteLine($"[DIAGNOSTIC] Executing delete on DbSet..."); #if NET7_0_OR_GREATER // EF Core 7+: Use ExecuteDeleteAsync for better performance (set-based delete without loading entities) await dbSet.ExecuteDeleteAsync(); - Console.WriteLine($"[DIAGNOSTIC] ExecuteDeleteAsync completed"); #else // EF Core 3.1-6.0: Fall back to traditional approach var existingRules = await dbSet.ToListAsync(); dbSet.RemoveRange(existingRules); await context.SaveChangesAsync(); - Console.WriteLine($"[DIAGNOSTIC] SaveChangesAsync completed after RemoveRange"); #endif } - Console.WriteLine($"[DIAGNOSTIC] Phase 2: Adding new policies to {policiesByContext.Count} contexts"); // Add new policies to respective contexts for (int i = 0; i < policiesByContext.Count; i++) { var group = policiesByContext[i]; var context = group.Key; - Console.WriteLine($"[DIAGNOSTIC] Adding policies {i + 1}/{policiesByContext.Count} to context: {context.GetType().Name}"); var dbSet = GetCasbinRuleDbSet(context, null); var saveRules = OnSavePolicy(store, group); - Console.WriteLine($"[DIAGNOSTIC] Adding {saveRules.Count()} policies to DbSet"); await dbSet.AddRangeAsync(saveRules); - Console.WriteLine($"[DIAGNOSTIC] Calling SaveChangesAsync..."); await context.SaveChangesAsync(); - Console.WriteLine($"[DIAGNOSTIC] SaveChangesAsync completed"); } - Console.WriteLine($"[DIAGNOSTIC] Committing transaction..."); await transaction.CommitAsync(); - Console.WriteLine($"[DIAGNOSTIC] Transaction committed successfully"); // Clear transaction state from all contexts to prevent SAVEPOINT errors foreach (var context in contexts) { context.Database.UseTransaction(null); } - Console.WriteLine($"[DIAGNOSTIC] Cleared transaction state from all contexts"); } - catch (Exception ex) + catch { - Console.WriteLine($"[DIAGNOSTIC] EXCEPTION CAUGHT: {ex.GetType().Name}: {ex.Message}"); - Console.WriteLine($"[DIAGNOSTIC] Rolling back transaction..."); await transaction.RollbackAsync(); - Console.WriteLine($"[DIAGNOSTIC] Transaction rolled back"); // Clear transaction state from all contexts foreach (var context in contexts) { context.Database.UseTransaction(null); } - Console.WriteLine($"[DIAGNOSTIC] Cleared transaction state from all contexts after rollback"); throw; } } @@ -659,24 +607,18 @@ private async Task SavePolicyWithIndividualTransactionsAsync(IPolicyStore store, public virtual void AddPolicy(string section, string policyType, IPolicyValues values) { - Console.WriteLine($"[ADAPTER] AddPolicy INVOKED: policyType={policyType}, section={section}"); - Console.WriteLine($"[ADAPTER] Call stack: {new System.Diagnostics.StackTrace(1, true)}"); - if (values.Count is 0) { - Console.WriteLine($"[ADAPTER] AddPolicy: No values provided, returning"); return; } var context = GetContextForPolicyType(policyType); - Console.WriteLine($"[ADAPTER] Context: {context.GetType().Name}"); var dbSet = GetCasbinRuleDbSetForPolicyType(context, policyType); var filter = new PolicyFilter(policyType, 0, values); var persistPolicies = filter.Apply(dbSet); if (persistPolicies.Any()) { - Console.WriteLine($"[ADAPTER] AddPolicy: Policy already exists, returning"); return; } @@ -684,24 +626,17 @@ public virtual void AddPolicy(string section, string policyType, IPolicyValues v // EF Core will create implicit transaction for SaveChanges() // This prevents SAVEPOINT errors when multiple operations are called sequentially InternalAddPolicy(section, policyType, values); - Console.WriteLine($"[ADAPTER] Calling context.SaveChanges() to commit immediately"); context.SaveChanges(); - Console.WriteLine($"[ADAPTER] SaveChanges() completed"); } public virtual async Task AddPolicyAsync(string section, string policyType, IPolicyValues values) { - Console.WriteLine($"[ADAPTER] AddPolicyAsync INVOKED: policyType={policyType}, section={section}"); - Console.WriteLine($"[ADAPTER] Call stack: {new System.Diagnostics.StackTrace(1, true)}"); - if (values.Count is 0) { - Console.WriteLine($"[ADAPTER] AddPolicyAsync: No values provided, returning"); return; } var context = GetContextForPolicyType(policyType); - Console.WriteLine($"[ADAPTER] Context: {context.GetType().Name}"); var dbSet = GetCasbinRuleDbSetForPolicyType(context, policyType); var filter = new PolicyFilter(policyType, 0, values); @@ -709,7 +644,6 @@ public virtual async Task AddPolicyAsync(string section, string policyType, IPol if (persistPolicies.Any()) { - Console.WriteLine($"[ADAPTER] AddPolicyAsync: Policy already exists, returning"); return; } @@ -717,9 +651,7 @@ public virtual async Task AddPolicyAsync(string section, string policyType, IPol // EF Core will create implicit transaction for SaveChangesAsync() // This prevents SAVEPOINT errors when multiple operations are called sequentially await InternalAddPolicyAsync(section, policyType, values); - Console.WriteLine($"[ADAPTER] Calling context.SaveChangesAsync() to commit immediately"); await context.SaveChangesAsync(); - Console.WriteLine($"[ADAPTER] SaveChangesAsync completed"); } public virtual void AddPolicies(string section, string policyType, IReadOnlyList valuesList) diff --git a/MULTI_CONTEXT_DESIGN.md b/MULTI_CONTEXT_DESIGN.md index 8470c25..7a0a5e6 100644 --- a/MULTI_CONTEXT_DESIGN.md +++ b/MULTI_CONTEXT_DESIGN.md @@ -609,7 +609,7 @@ dotnet test --filter "FullyQualifiedName~SavePolicy_WhenDuplicateKeyViolationInO **Prerequisites to run integration tests:** - PostgreSQL running on localhost:5432 - Database `casbin_integration_test` must exist (schemas created automatically) -- Connection credentials: postgres/postgres (or update ConnectionString in fixture) +- Connection credentials: postgres/postgres4all! (or update ConnectionString in fixture) **Failure Simulation:** - Duplicate key violations (via direct SQL INSERT) From 15999aaea24c56ba6922215a7ce3c5c63f932e0f Mon Sep 17 00:00:00 2001 From: Thor Arne Johansen Date: Thu, 13 Nov 2025 14:08:05 +0100 Subject: [PATCH 07/11] docs: Update MULTI_CONTEXT_DESIGN.md to reflect actual tests Remove reference to non-existent test SavePolicy_WhenDuplicateKeyViolationInOneContext_ShouldRollbackAllContexts and update example command to use an actual test name. --- MULTI_CONTEXT_DESIGN.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/MULTI_CONTEXT_DESIGN.md b/MULTI_CONTEXT_DESIGN.md index 7a0a5e6..6b5199f 100644 --- a/MULTI_CONTEXT_DESIGN.md +++ b/MULTI_CONTEXT_DESIGN.md @@ -577,7 +577,6 @@ Transaction integrity guarantees are verified by comprehensive integration tests |------|---------|----------------| | `SavePolicy_WithSharedConnection_ShouldWriteToAllContextsAtomically` | Happy path atomic write | Policies written to 3 schemas in single transaction | | `MultiContextSetup_WithSharedConnection_ShouldShareSamePhysicalConnection` | Connection sharing | Reference equality check confirms shared DbConnection object | -| `SavePolicy_WhenDuplicateKeyViolationInOneContext_ShouldRollbackAllContexts` | **CRITICAL** Rollback on constraint violation | Failure in one context rolls back all contexts atomically | | `SavePolicy_WhenTableMissingInOneContext_ShouldRollbackAllContexts` | Rollback on severe failure | Missing table in one context rolls back all contexts | | `MultipleSaveOperations_WithSharedConnection_ShouldMaintainDataConsistency` | Consistency over time | Multiple incremental saves maintain integrity | | `SavePolicy_WithSeparateConnections_ShouldNotBeAtomic` | **Negative test** | Proves separate connections do NOT provide atomicity | @@ -590,7 +589,7 @@ Transaction integrity guarantees are verified by comprehensive integration tests dotnet test --filter "Category=Integration" # Run specific test -dotnet test --filter "FullyQualifiedName~SavePolicy_WhenDuplicateKeyViolationInOneContext_ShouldRollbackAllContexts" +dotnet test --filter "FullyQualifiedName~SavePolicy_WithSharedConnection_ShouldWriteToAllContextsAtomically" ``` **Note:** Integration tests are excluded from CI/CD (marked with `[Trait("Category", "Integration")]`) as they: From 63c504209f535d9d814e62f8e06d20f99ca0905e Mon Sep 17 00:00:00 2001 From: Thor Arne Johansen Date: Thu, 13 Nov 2025 14:27:36 +0100 Subject: [PATCH 08/11] ci: Exclude integration tests from CI/CD workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --filter "Category!=Integration" to dotnet test commands in both build.yml and release.yml workflows to prevent integration tests from running in CI/CD. Integration tests require PostgreSQL with specific configuration and should only be run locally. They are properly marked with [Trait("Category", "Integration")] and can be run locally with: dotnet test --filter "Category=Integration" This ensures: - Only unit tests (186 tests × 6 frameworks) run in CI/CD - Integration tests (19 tests × 6 frameworks) excluded from CI/CD - No PostgreSQL dependency in CI/CD pipeline --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f9fc6f9..1114d92 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,7 +47,7 @@ jobs: run: dotnet build -c Release --no-restore - name: Test solution - run: dotnet test -c Release --no-build --no-restore --verbosity normal --results-directory test-results --collect:"XPlat Code Coverage" ` + run: dotnet test -c Release --no-build --no-restore --verbosity normal --filter "Category!=Integration" --results-directory test-results --collect:"XPlat Code Coverage" ` -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=json,cobertura,lcov,teamcity,opencover - name: Upload coverage diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c6eb3e..a151035 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,7 @@ jobs: run: dotnet build -c Release --no-restore - name: Test solution - run: dotnet test -c Release --no-build --no-restore --verbosity normal --results-directory test-results --collect:"XPlat Code Coverage" ` + run: dotnet test -c Release --no-build --no-restore --verbosity normal --filter "Category!=Integration" --results-directory test-results --collect:"XPlat Code Coverage" ` -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=json,cobertura,lcov,teamcity,opencover - name: Upload coverage From caafae01dee7041abf6d10bdb484375d9dfc0375 Mon Sep 17 00:00:00 2001 From: Thor Arne Johansen Date: Mon, 17 Nov 2025 10:55:06 +0100 Subject: [PATCH 09/11] fix: properly dispose database connections in integration tests Updated CreateEnforcerWithSeparateConnectionsAsync to return tuple with all disposable resources (connections and contexts) and updated call site to properly dispose them in finally block. This resolves resource leaks detected by IDE analysis. --- .../Integration/TransactionIntegrityTests.cs | 94 +++++++++++-------- 1 file changed, 57 insertions(+), 37 deletions(-) diff --git a/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTests.cs b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTests.cs index b19349f..a68c8ec 100644 --- a/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTests.cs +++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTests.cs @@ -168,7 +168,13 @@ public IEnumerable GetAllContexts() /// Creates an enforcer with three contexts using SEPARATE DbConnections (same connection string) /// This is used to demonstrate non-atomic behavior when connections are not shared. /// - private async Task CreateEnforcerWithSeparateConnectionsAsync() + private async Task<(Enforcer enforcer, + NpgsqlConnection policyConnection, + NpgsqlConnection groupingConnection, + NpgsqlConnection roleConnection, + CasbinDbContext policyContext, + CasbinDbContext groupingContext, + CasbinDbContext roleContext)> CreateEnforcerWithSeparateConnectionsAsync() { // Create THREE separate connections with same connection string var policyConnection = new NpgsqlConnection(_fixture.ConnectionString); @@ -194,7 +200,8 @@ private async Task CreateEnforcerWithSeparateConnectionsAsync() await enforcer.LoadPolicyAsync(); - return enforcer; + return (enforcer, policyConnection, groupingConnection, roleConnection, + policyContext, groupingContext, roleContext); } private CasbinDbContext CreateContext(NpgsqlConnection connection, string schemaName) @@ -476,50 +483,63 @@ public async Task MultipleSaveOperations_WithSharedConnection_ShouldMaintainData public async Task SavePolicy_WithSeparateConnections_ShouldNotBeAtomic() { // Arrange - Create enforcer with SEPARATE connection objects - var enforcer = await CreateEnforcerWithSeparateConnectionsAsync(); + var (enforcer, policyConnection, groupingConnection, roleConnection, + policyContext, groupingContext, roleContext) = await CreateEnforcerWithSeparateConnectionsAsync(); - // Add policies to all contexts - await enforcer.AddPolicyAsync("alice", "data1", "read"); - await enforcer.AddGroupingPolicyAsync("alice", "admin"); - await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser"); + try + { + // Add policies to all contexts + await enforcer.AddPolicyAsync("alice", "data1", "read"); + await enforcer.AddGroupingPolicyAsync("alice", "admin"); + await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser"); - // Drop table in roles schema to force failure - await _fixture.DropTableAsync(TransactionIntegrityTestFixture.RolesSchema); + // Drop table in roles schema to force failure + await _fixture.DropTableAsync(TransactionIntegrityTestFixture.RolesSchema); - Exception? caughtException = null; + Exception? caughtException = null; - // Act - Try to save, should throw due to missing table in roles schema - try - { - await enforcer.SavePolicyAsync(); - } - catch (Exception ex) - { - caughtException = ex; - } + // Act - Try to save, should throw due to missing table in roles schema + try + { + await enforcer.SavePolicyAsync(); + } + catch (Exception ex) + { + caughtException = ex; + } - // Assert - Verify exception was thrown - Assert.NotNull(caughtException); + // Assert - Verify exception was thrown + Assert.NotNull(caughtException); - // Recreate table for verification queries - await _fixture.RunMigrationsAsync(); + // Recreate table for verification queries + await _fixture.RunMigrationsAsync(); - // CRITICAL ASSERTION - Verify policies WERE written to functioning contexts (NOT atomic!) - var policiesCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.PoliciesSchema, "p"); - var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.GroupingsSchema, "g"); - var rolesCount = await _fixture.CountPoliciesInSchemaAsync( - TransactionIntegrityTestFixture.RolesSchema, "g2"); + // CRITICAL ASSERTION - Verify policies WERE written to functioning contexts (NOT atomic!) + var policiesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.PoliciesSchema, "p"); + var groupingsCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.GroupingsSchema, "g"); + var rolesCount = await _fixture.CountPoliciesInSchemaAsync( + TransactionIntegrityTestFixture.RolesSchema, "g2"); - // This test DOCUMENTS the non-atomic behavior without shared connections - // Policies and groupings were committed despite roles context failure - Assert.Equal(1, policiesCount); // Written (NOT rolled back) - Assert.Equal(1, groupingsCount); // Written (NOT rolled back) - Assert.Equal(0, rolesCount); // Failed to write (table dropped) + // This test DOCUMENTS the non-atomic behavior without shared connections + // Policies and groupings were committed despite roles context failure + Assert.Equal(1, policiesCount); // Written (NOT rolled back) + Assert.Equal(1, groupingsCount); // Written (NOT rolled back) + Assert.Equal(0, rolesCount); // Failed to write (table dropped) - // This proves that connection string matching alone is INSUFFICIENT for atomicity - // Must use shared DbConnection OBJECT for atomic transactions + // This proves that connection string matching alone is INSUFFICIENT for atomicity + // Must use shared DbConnection OBJECT for atomic transactions + } + finally + { + await policyContext.DisposeAsync(); + await groupingContext.DisposeAsync(); + await roleContext.DisposeAsync(); + await policyConnection.DisposeAsync(); + await groupingConnection.DisposeAsync(); + await roleConnection.DisposeAsync(); + } } #endregion From 4231dfb79dcdbb1ccf61bd16555702c15f31b56e Mon Sep 17 00:00:00 2001 From: Thor Arne Johansen Date: Mon, 17 Nov 2025 11:05:12 +0100 Subject: [PATCH 10/11] refactor: remove unused variables from unit tests Removed initialPolicyCount and initialGroupingCount variables from TestMultiContextUpdatePolicyNoException as they were assigned but never used. Addresses code review feedback. --- Casbin.Persist.Adapter.EFCore.UnitTest/MultiContextTest.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/MultiContextTest.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/MultiContextTest.cs index 4c9e294..2525777 100644 --- a/Casbin.Persist.Adapter.EFCore.UnitTest/MultiContextTest.cs +++ b/Casbin.Persist.Adapter.EFCore.UnitTest/MultiContextTest.cs @@ -367,9 +367,6 @@ public void TestMultiContextUpdatePolicyNoException() enforcer.AddPolicy("alice", "data1", "read"); enforcer.AddGroupingPolicy("alice", "admin"); - var initialPolicyCount = policyContext.Policies.Count(); - var initialGroupingCount = groupingContext.Policies.Count(); - // Act & Assert - UpdatePolicy should complete without throwing exceptions enforcer.UpdatePolicy( AsList("alice", "data1", "read"), From ac4c28c5ff017c76edc6d8f205129b92036a21dc Mon Sep 17 00:00:00 2001 From: Thor Arne Johansen Date: Mon, 17 Nov 2025 12:08:39 +0100 Subject: [PATCH 11/11] refactor: replace obsolete GetCasbinRuleDbSet method call Updated PersistPolicies property to call the two-parameter version of GetCasbinRuleDbSet instead of the obsolete one-parameter version. This maintains virtual method extensibility while eliminating the CS0618 obsolete method warning. --- Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs b/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs index 587a1f8..f0a7f02 100644 --- a/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs +++ b/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs @@ -87,7 +87,7 @@ public partial class EFCoreAdapter : IAdapter, private readonly Dictionary<(DbContext context, string policyType), DbSet> _persistPoliciesByContext; protected TDbContext DbContext { get; } - protected DbSet PersistPolicies => _persistPolicies ??= GetCasbinRuleDbSet(DbContext); + protected DbSet PersistPolicies => _persistPolicies ??= GetCasbinRuleDbSet(DbContext, null); /// /// Creates adapter with single context (backward compatible)