using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using ROLAC.API.Authorization; using ROLAC.API.Data; using ROLAC.API.DTOs.Permissions; using ROLAC.API.Entities; using ROLAC.API.Services; using Xunit; namespace ROLAC.API.Tests.Services; public class PermissionServiceTests { // ----------------------------------------------------------------------- // Harness: a real PermissionService backed by an in-memory EF database. // ----------------------------------------------------------------------- private sealed class Harness { public required ServiceProvider Provider { get; init; } public required PermissionService Service { get; init; } public async Task SeedRoleAsync(string roleName, params RolePermission[] permissions) { using var scope = Provider.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var role = new AppRole { Id = $"role-{roleName}", Name = roleName, NormalizedName = roleName.ToUpperInvariant() }; db.Roles.Add(role); foreach (var permission in permissions) { permission.RoleId = role.Id; db.RolePermissions.Add(permission); } await db.SaveChangesAsync(); } } private static Harness BuildHarness() { var dbName = Guid.NewGuid().ToString(); var services = new ServiceCollection(); services.AddDbContext(opt => opt.UseInMemoryDatabase(dbName)); var provider = services.BuildServiceProvider(); var scopeFactory = provider.GetRequiredService(); var cache = new MemoryCache(new MemoryCacheOptions()); return new Harness { Provider = provider, Service = new PermissionService(scopeFactory, cache, new ROLAC.API.Services.Logging.SystemLogQueue(), new Microsoft.AspNetCore.Http.HttpContextAccessor()), }; } private static RolePermission Perm(string module, bool r = false, bool w = false, bool d = false, bool a = false) => new() { Module = module, CanRead = r, CanWrite = w, CanDelete = d, CanApprove = a }; // ----------------------------------------------------------------------- // HasPermissionAsync // ----------------------------------------------------------------------- [Fact] public async Task HasPermission_RoleGrantsAction_ReturnsTrue() { var h = BuildHarness(); await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true, w: true)); Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Read)); Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write)); } [Fact] public async Task HasPermission_RoleLacksAction_ReturnsFalse() { var h = BuildHarness(); await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true)); // read only Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Delete)); Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Members, PermissionActions.Read)); } [Fact] public async Task HasPermission_UnionAcrossRoles_ReturnsTrueIfAnyRoleGrants() { var h = BuildHarness(); await h.SeedRoleAsync("pastor", Perm(Modules.Members, r: true)); await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true, w: true)); // User holds both roles — should get the union. Assert.True(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Members, PermissionActions.Read)); Assert.True(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Givings, PermissionActions.Write)); Assert.False(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Members, PermissionActions.Delete)); } // ----------------------------------------------------------------------- // GetEffectivePermissionsAsync // ----------------------------------------------------------------------- [Fact] public async Task GetEffectivePermissions_SuperAdmin_ReturnsAllModulesFull() { var h = BuildHarness(); // no rows seeded at all var effective = await h.Service.GetEffectivePermissionsAsync(["super_admin"]); Assert.Equal(Modules.All.Count, effective.Count); foreach (var module in Modules.All) { Assert.True(effective[module].Read); Assert.True(effective[module].Write); Assert.True(effective[module].Delete); Assert.True(effective[module].Approve); } } [Fact] public async Task GetEffectivePermissions_MergesFlagsAcrossRoles() { var h = BuildHarness(); await h.SeedRoleAsync("a", Perm(Modules.Expenses, r: true)); await h.SeedRoleAsync("b", Perm(Modules.Expenses, w: true, a: true)); var effective = await h.Service.GetEffectivePermissionsAsync(["a", "b"]); Assert.True(effective[Modules.Expenses].Read); Assert.True(effective[Modules.Expenses].Write); Assert.True(effective[Modules.Expenses].Approve); Assert.False(effective[Modules.Expenses].Delete); } [Fact] public async Task GetEffectivePermissions_OmitsModulesWithNoGrant() { var h = BuildHarness(); await h.SeedRoleAsync("member"); // role exists but no grants var effective = await h.Service.GetEffectivePermissionsAsync(["member"]); Assert.Empty(effective); } // ----------------------------------------------------------------------- // Caching / invalidation via UpsertRoleAsync // ----------------------------------------------------------------------- [Fact] public async Task UpsertRole_InvalidatesCache_SoNextCheckReflectsNewState() { var h = BuildHarness(); await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true)); // read only // Prime the cache with the original snapshot. Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write)); // Grant write; UpsertRoleAsync must invalidate the cache. await h.Service.UpsertRoleAsync("finance", [new ModulePermissionDto { Module = Modules.Givings, CanRead = true, CanWrite = true, }]); Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write)); } [Fact] public async Task UpsertRole_SuperAdmin_Throws() { var h = BuildHarness(); await h.SeedRoleAsync("super_admin"); await Assert.ThrowsAsync( () => h.Service.UpsertRoleAsync("super_admin", [new ModulePermissionDto { Module = Modules.Members, CanRead = true }])); } [Fact] public async Task UpsertRole_UnknownRole_Throws() { var h = BuildHarness(); await Assert.ThrowsAsync( () => h.Service.UpsertRoleAsync("ghost", [new ModulePermissionDto { Module = Modules.Members, CanRead = true }])); } }