Add role control
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Moq;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Authorization;
|
||||
|
||||
public class PermissionAuthorizationHandlerTests
|
||||
{
|
||||
private static ClaimsPrincipal UserWithRoles(params string[] roles)
|
||||
{
|
||||
var claims = roles.Select(role => new Claim("role", role));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test"));
|
||||
}
|
||||
|
||||
private static async Task<bool> EvaluateAsync(
|
||||
ClaimsPrincipal user, PermissionRequirement requirement, IPermissionService permissions)
|
||||
{
|
||||
var handler = new PermissionAuthorizationHandler(permissions);
|
||||
var context = new AuthorizationHandlerContext([requirement], user, resource: null);
|
||||
await handler.HandleAsync(context);
|
||||
return context.HasSucceeded;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuperAdmin_AlwaysSucceeds_WithoutConsultingMatrix()
|
||||
{
|
||||
var permissions = new Mock<IPermissionService>(MockBehavior.Strict); // must NOT be called
|
||||
var requirement = new PermissionRequirement(Modules.Members, PermissionActions.Delete);
|
||||
|
||||
var succeeded = await EvaluateAsync(UserWithRoles("super_admin"), requirement, permissions.Object);
|
||||
|
||||
Assert.True(succeeded);
|
||||
permissions.Verify(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoleWithPermission_Succeeds()
|
||||
{
|
||||
var permissions = new Mock<IPermissionService>();
|
||||
permissions.Setup(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), Modules.Members, PermissionActions.Write))
|
||||
.ReturnsAsync(true);
|
||||
var requirement = new PermissionRequirement(Modules.Members, PermissionActions.Write);
|
||||
|
||||
var succeeded = await EvaluateAsync(UserWithRoles("secretary"), requirement, permissions.Object);
|
||||
|
||||
Assert.True(succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoleWithoutPermission_Fails()
|
||||
{
|
||||
var permissions = new Mock<IPermissionService>();
|
||||
permissions.Setup(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(false);
|
||||
var requirement = new PermissionRequirement(Modules.Givings, PermissionActions.Write);
|
||||
|
||||
var succeeded = await EvaluateAsync(UserWithRoles("member"), requirement, permissions.Object);
|
||||
|
||||
Assert.False(succeeded);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Auth;
|
||||
using ROLAC.API.DTOs.Permissions;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
@@ -72,11 +73,20 @@ public class AuthServiceTests
|
||||
return svc;
|
||||
}
|
||||
|
||||
/// <summary>IPermissionService mock: returns an empty effective-permission map.</summary>
|
||||
private static Mock<IPermissionService> BuildPermissionService()
|
||||
{
|
||||
var svc = new Mock<IPermissionService>();
|
||||
svc.Setup(p => p.GetEffectivePermissionsAsync(It.IsAny<IEnumerable<string>>()))
|
||||
.ReturnsAsync(new Dictionary<string, ModuleActions>());
|
||||
return svc;
|
||||
}
|
||||
|
||||
private static AuthService BuildSut(
|
||||
Mock<UserManager<AppUser>> umMock,
|
||||
Mock<ITokenService> tsMock,
|
||||
AppDbContext db)
|
||||
=> new(umMock.Object, tsMock.Object, db, BuildConfig());
|
||||
=> new(umMock.Object, tsMock.Object, db, BuildPermissionService().Object, BuildConfig());
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Login tests
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
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<AppDbContext>();
|
||||
|
||||
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<AppDbContext>(opt => opt.UseInMemoryDatabase(dbName));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
return new Harness
|
||||
{
|
||||
Provider = provider,
|
||||
Service = new PermissionService(scopeFactory, cache),
|
||||
};
|
||||
}
|
||||
|
||||
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<InvalidOperationException>(
|
||||
() => 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<KeyNotFoundException>(
|
||||
() => h.Service.UpsertRoleAsync("ghost", [new ModulePermissionDto { Module = Modules.Members, CanRead = true }]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user