using System.Security.Claims; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; using ERPCore.Dtos.Config; using ERPCore.Dtos.Users; using ERPCore.Infra.Auth; using ERPCore.Infra.Persistence; using ERPCore.Repositories; using ERPCore.Services; using ERPCore.System.Errors; using ERPCore.Tests.TestSupport; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using static ERPCore.Tests.TestSupport.BankDepositTestSupport; namespace ERPCore.Tests; /// /// The ERP-owned role model: AuthHex proves who someone is; the role, its permissions and "is admin" come from the /// ERP's own users/roles tables, are read on every request, and a change applies on the very next one. /// public class AccessControlTests { private const int AdminRole = 1, ApproverRole = 2, ViewerRole = 3, NoAccessRole = 4, InactiveRole = 5; // ---------------------------------------------------------------- helpers private sealed class FakeUser(int userId = 10, int? roleId = null, bool isAdmin = false) : ICurrentUser { public int AuditUserId { get; set; } = userId; public int? RoleId { get; set; } = roleId; public string? RoleCode { get; set; } public bool IsAdmin { get; set; } = isAdmin; public string UserId => AuditUserId.ToString(); public bool IsAuthenticated => true; public int? BranchId => null; } private static async Task SeedAsync(bool withDefault = true) { var db = CreateContext(); db.Roles.AddRange( new Role { RoleId = AdminRole, Code = "Admin", Name = "Administrator", IsAdmin = true, IsSystemRole = true, CreatedAt = DateTime.UtcNow }, new Role { RoleId = ApproverRole, Code = "Approver", Name = "Approver", CreatedAt = DateTime.UtcNow }, new Role { RoleId = ViewerRole, Code = "Viewer", Name = "Viewer", CreatedAt = DateTime.UtcNow }, new Role { RoleId = NoAccessRole, Code = "NoAccess", Name = "No access", IsSystemRole = true, CreatedAt = DateTime.UtcNow }, new Role { RoleId = InactiveRole, Code = "Retired", Name = "Retired", Status = EntityStatus.Inactive, CreatedAt = DateTime.UtcNow }); db.NavItems.Add(new NavItem { NavItemId = 100, Code = "procurement", Label = "Procurement" }); db.SubNavItems.Add(new SubNavItem { SubNavItemId = 101, NavItemId = 100, Code = "procurement.vendors", Label = "Vendors" }); db.NavItems.Add(new NavItem { NavItemId = 102, Code = "stock", Label = "Stock" }); db.Permissions.AddRange( new Permission { PermissionId = 200, Code = "NAV:procurement", NavItemId = 100 }, new Permission { PermissionId = 201, Code = "NAV:procurement.vendors", SubNavItemId = 101 }, new Permission { PermissionId = 202, Code = "NAV:stock", NavItemId = 102 }); db.RolePermissions.AddRange( new RolePermission { RoleId = ApproverRole, PermissionId = 200, Actions = RolePermissionAction.View | RolePermissionAction.Approve }, new RolePermission { RoleId = ViewerRole, PermissionId = 201, Actions = RolePermissionAction.View }, new RolePermission { RoleId = InactiveRole, PermissionId = 202, Actions = RolePermissionAction.All }); if (withDefault) db.AccessConfig.Add(new AccessConfig { ConfigId = AccessConfig.SingletonId, DefaultRoleId = NoAccessRole }); await db.SaveChangesAsync(); return db; } private static ClaimsPrincipal Principal(Guid authUserId, string? tokenRoleCode = null) { var claims = new List { new("UserId", authUserId.ToString()), new("NIC", "N-1") }; if (tokenRoleCode is not null) claims.Add(new Claim("RoleCode", tokenRoleCode)); return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); } private static ShadowUserClaimsTransformation Transformer(ErpDbContext db, bool legacyFallback = true) => new(db, new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Auth:LegacyRoleClaimFallback"] = legacyFallback.ToString() }) .Build()); private static string? Claim(ClaimsPrincipal p, string type) => p.FindFirstValue(type); private static User NewUser(Guid authId, int? roleId, EntityStatus status = EntityStatus.Active, int id = 0) => new() { UserId = id == 0 ? Random.Shared.Next(100, 100000) : id, AuthUserId = authId, Username = $"u-{authId:N}", DisplayName = "Someone", RoleId = roleId, Status = status }; private static NavPermissionService NavService(ErpDbContext db) => new(new Repository(db), new Repository(db), new MemoryCache(new MemoryCacheOptions())); private static RoleService RoleSvc(ErpDbContext db, ICurrentUser user, NavPermissionService nav) => new(new Repository(db), new Repository(db), new Repository(db), new Repository(db), new Repository(db), new Repository(db), user, nav, new FakeBranchConfig(), new PassThroughUnitOfWork(db)); // The AuthHex client, link and employee services are not touched by role/status changes (users here have no AuthUserId). private static UserManagementService UserSvc(ErpDbContext db, ICurrentUser user) => new(new Repository(db), new Repository(db), new Repository(db), null!, null!, null!, user, new PassThroughUnitOfWork(db)); // ---------------------------------------------------------------- claims: the role comes from the ERP [Fact] public async Task Role_IsReadFromTheErpTable_NotFromTheTokensRoleClaim() { await using var db = await SeedAsync(); var auth = Guid.NewGuid(); db.Users.Add(NewUser(auth, ApproverRole)); await db.SaveChangesAsync(); // The token still says "Admin" (an old AuthHex token). The ERP says Approver, and the ERP wins. var principal = await Transformer(db).TransformAsync(Principal(auth, tokenRoleCode: "Admin")); Assert.Equal(ApproverRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId)); Assert.Equal("Approver", Claim(principal, AuthHexClaims.ErpRoleCode)); Assert.Equal("false", Claim(principal, AuthHexClaims.ErpIsAdmin)); } [Fact] public async Task Admin_IsTheRoleFlag_NotTheRoleName() { await using var db = await SeedAsync(); var boss = Guid.NewGuid(); var impostor = Guid.NewGuid(); db.Roles.Add(new Role { RoleId = 20, Code = "Boss", Name = "The boss", IsAdmin = true, CreatedAt = DateTime.UtcNow }); db.Roles.Add(new Role { RoleId = 21, Code = "ADMIN2", Name = "admin", CreatedAt = DateTime.UtcNow }); // named like an admin, is not db.Users.AddRange(NewUser(boss, 20), NewUser(impostor, 21)); await db.SaveChangesAsync(); Assert.Equal("true", Claim(await Transformer(db).TransformAsync(Principal(boss)), AuthHexClaims.ErpIsAdmin)); Assert.Equal("false", Claim(await Transformer(db).TransformAsync(Principal(impostor)), AuthHexClaims.ErpIsAdmin)); } [Fact] public async Task RenamingTheAdminRole_DoesNotCostAnyoneTheirAdminRights() { await using var db = await SeedAsync(); var auth = Guid.NewGuid(); db.Users.Add(NewUser(auth, AdminRole)); await db.SaveChangesAsync(); db.Roles.Single(r => r.RoleId == AdminRole).Code = "Owners"; await db.SaveChangesAsync(); var principal = await Transformer(db).TransformAsync(Principal(auth)); Assert.Equal("true", Claim(principal, AuthHexClaims.ErpIsAdmin)); Assert.Equal("Owners", Claim(principal, AuthHexClaims.ErpRoleCode)); } [Fact] public async Task FirstSignIn_CreatesTheUser_AndGivesItTheDefaultRole() { await using var db = await SeedAsync(); var auth = Guid.NewGuid(); var principal = await Transformer(db).TransformAsync(Principal(auth)); var user = Assert.Single(db.Users.Where(u => u.AuthUserId == auth)); Assert.Equal(NoAccessRole, user.RoleId); // saved, so the Users screen shows it Assert.Equal(NoAccessRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId)); Assert.Equal("false", Claim(principal, AuthHexClaims.ErpIsAdmin)); Assert.Equal(user.UserId.ToString(), Claim(principal, ClaimTypes.NameIdentifier)); } [Fact] public async Task WithNoDefaultRole_APersonWithoutARoleHasNoRoleClaims() { await using var db = await SeedAsync(withDefault: false); var principal = await Transformer(db).TransformAsync(Principal(Guid.NewGuid())); Assert.Null(Claim(principal, AuthHexClaims.ErpRoleId)); Assert.Null(Claim(principal, AuthHexClaims.ErpIsAdmin)); Assert.NotNull(Claim(principal, ClaimTypes.NameIdentifier)); // still a known user } [Fact] public async Task LegacyFallback_UsesTheTokensRole_ForARolelessUser_AndSavesIt() { await using var db = await SeedAsync(); var auth = Guid.NewGuid(); db.Users.Add(NewUser(auth, roleId: null)); await db.SaveChangesAsync(); var principal = await Transformer(db, legacyFallback: true).TransformAsync(Principal(auth, "Approver")); Assert.Equal(ApproverRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId)); Assert.Equal(ApproverRole, db.Users.Single(u => u.AuthUserId == auth).RoleId); } [Fact] public async Task LegacyFallbackOff_IgnoresTheTokenAndUsesTheDefault() { await using var db = await SeedAsync(); var auth = Guid.NewGuid(); db.Users.Add(NewUser(auth, roleId: null)); await db.SaveChangesAsync(); var principal = await Transformer(db, legacyFallback: false).TransformAsync(Principal(auth, "Admin")); Assert.Equal(NoAccessRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId)); Assert.Equal("false", Claim(principal, AuthHexClaims.ErpIsAdmin)); } [Fact] public async Task AnAssignedRole_IsNeverOverwrittenByTheFallbackOrTheDefault() { await using var db = await SeedAsync(); var auth = Guid.NewGuid(); db.Users.Add(NewUser(auth, ViewerRole)); await db.SaveChangesAsync(); var principal = await Transformer(db).TransformAsync(Principal(auth, "Admin")); Assert.Equal(ViewerRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId)); Assert.Equal(ViewerRole, db.Users.Single(u => u.AuthUserId == auth).RoleId); } [Fact] public async Task InactiveUser_IsBlocked_AndGetsNoRole() { await using var db = await SeedAsync(); var auth = Guid.NewGuid(); db.Users.Add(NewUser(auth, AdminRole, EntityStatus.Inactive)); await db.SaveChangesAsync(); var principal = await Transformer(db).TransformAsync(Principal(auth)); Assert.Equal("1", Claim(principal, AuthHexClaims.ErpBlocked)); Assert.Null(Claim(principal, AuthHexClaims.ErpRoleId)); Assert.Null(Claim(principal, AuthHexClaims.ErpIsAdmin)); } [Fact] public async Task InactiveRole_GrantsNothing() { await using var db = await SeedAsync(); var auth = Guid.NewGuid(); db.Users.Add(NewUser(auth, InactiveRole)); await db.SaveChangesAsync(); var principal = await Transformer(db).TransformAsync(Principal(auth)); Assert.Null(Claim(principal, AuthHexClaims.ErpRoleId)); Assert.Null(Claim(principal, AuthHexClaims.ErpBlocked)); // not blocked — just no permissions } [Fact] public async Task Branch_ComesFromTheLinkedEmployee_AndTransformingTwiceAddsNothingNew() { await using var db = await SeedAsync(); var auth = Guid.NewGuid(); var user = NewUser(auth, ViewerRole, id: 500); db.Users.Add(user); db.Employees.Add(new Employee { EmployeeId = 1, EmployeeCode = "E1", FullName = "Emp", UserId = 500, BranchId = Branch2 }); await db.SaveChangesAsync(); var transformer = Transformer(db); var principal = await transformer.TransformAsync(Principal(auth)); var claimsAfterFirst = principal.Claims.Count(); principal = await transformer.TransformAsync(principal); Assert.Equal(Branch2.ToString(), Claim(principal, AuthHexClaims.BranchId)); Assert.Equal(claimsAfterFirst, principal.Claims.Count()); } // ---------------------------------------------------------------- permissions by role id [Fact] public async Task Nav_AccessAndActions_AreDecidedByTheRolesPermissions() { await using var db = await SeedAsync(); var nav = NavService(db); Assert.True(await nav.HasAccessAsync(ApproverRole, "procurement")); Assert.True(await nav.HasActionAsync(ApproverRole, "procurement", RolePermissionAction.Approve)); Assert.False(await nav.HasActionAsync(ApproverRole, "procurement", RolePermissionAction.Delete)); Assert.False(await nav.HasAccessAsync(ApproverRole, "stock")); // A parent module is reachable through a child grant; an action is exact. Assert.True(await nav.HasAccessAsync(ViewerRole, "procurement")); Assert.False(await nav.HasActionAsync(ViewerRole, "procurement", RolePermissionAction.View)); Assert.True(await nav.HasActionAsync(ViewerRole, "procurement.vendors", RolePermissionAction.View)); Assert.False(await nav.HasAccessAsync(null, "procurement")); Assert.False(await nav.HasAccessAsync(999, "procurement")); } [Fact] public async Task Nav_InactiveRole_HasNoAccess() { await using var db = await SeedAsync(); Assert.False(await NavService(db).HasAccessAsync(InactiveRole, "stock")); } [Fact] public async Task EditingARolesPermissions_AppliesImmediately_NotAfterACacheTimeout() { await using var db = await SeedAsync(); var nav = NavService(db); var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), nav); Assert.False(await nav.HasAccessAsync(ViewerRole, "stock")); // cached as "no" await roles.AssignPermissionsAsync(ViewerRole, new Dtos.Rbac.AssignRolePermissionsRequest { NavItemIds = [102] }); Assert.True(await nav.HasAccessAsync(ViewerRole, "stock")); // visible straight away } // ---------------------------------------------------------------- /auth/me [Fact] public async Task Me_ForAnAdministrator_ListsEveryPermission_EvenOnesNeverGranted() { await using var db = await SeedAsync(); var me = await RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db)).GetMeAsync(); Assert.True(me.HasRole); Assert.True(me.IsAdmin); Assert.Equal(AdminRole, me.RoleId); Assert.Contains("procurement.vendors", me.NavCodes); Assert.Contains("stock", me.NavCodes); Assert.Equal((int)RolePermissionAction.All, me.Permissions!["stock"]); } [Fact] public async Task Me_ForAnOrdinaryRole_ListsOnlyItsGrants_WithTheirActions() { await using var db = await SeedAsync(); var me = await RoleSvc(db, new FakeUser(roleId: ApproverRole), NavService(db)).GetMeAsync(); Assert.True(me.HasRole); Assert.False(me.IsAdmin); Assert.Equal(["procurement"], me.NavCodes); Assert.Equal((int)(RolePermissionAction.View | RolePermissionAction.Approve), me.Permissions!["procurement"]); } [Fact] public async Task Me_WithoutARole_SaysSo_InsteadOfReturningAnEmptyMenu() { await using var db = await SeedAsync(); var me = await RoleSvc(db, new FakeUser(roleId: null), NavService(db)).GetMeAsync(); Assert.False(me.HasRole); Assert.Empty(me.NavCodes); Assert.Empty(me.Permissions!); } // ---------------------------------------------------------------- role management guards [Fact] public async Task TheAdministratorSystemAndDefaultRoles_CannotBeDeactivatedOrDeleted() { await using var db = await SeedAsync(); var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db)); await Assert.ThrowsAsync(() => roles.SetStatusAsync(AdminRole, EntityStatus.Inactive)); await Assert.ThrowsAsync(() => roles.DeleteAsync(AdminRole)); await Assert.ThrowsAsync(() => roles.DeleteAsync(NoAccessRole)); // system role and the default await Assert.ThrowsAsync(() => roles.SetStatusAsync(NoAccessRole, EntityStatus.Inactive)); } [Fact] public async Task ARoleInUse_CannotBeDeactivated_ButAnUnusedOneCanBeDeleted() { await using var db = await SeedAsync(); db.Users.Add(NewUser(Guid.NewGuid(), ViewerRole)); await db.SaveChangesAsync(); var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db)); await Assert.ThrowsAsync(() => roles.SetStatusAsync(ViewerRole, EntityStatus.Inactive)); await roles.DeleteAsync(ApproverRole); Assert.False(db.Roles.Any(r => r.RoleId == ApproverRole)); } [Fact] public async Task CreatedRoles_NeverCarryTheAdminFlag() { await using var db = await SeedAsync(); var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db)); var created = (await roles.CreateAsync(new Dtos.Rbac.CreateRoleRequest { Code = "Clerk", Name = "Clerk" })).Value; Assert.False(created.IsAdmin); Assert.False(created.IsSystemRole); Assert.Null(db.Roles.Single(r => r.RoleId == created.RoleId).AuthRoleId); } // ---------------------------------------------------------------- user management guards [Fact] public async Task ChangingAUsersRole_TakesEffectOnTheirNextRequest_WithNoRelogin() { await using var db = await SeedAsync(); var auth = Guid.NewGuid(); var user = NewUser(auth, ViewerRole, id: 700); db.Users.Add(user); await db.SaveChangesAsync(); Assert.Equal(ViewerRole.ToString(), Claim(await Transformer(db).TransformAsync(Principal(auth)), AuthHexClaims.ErpRoleId)); await UserSvc(db, new FakeUser(1, AdminRole, true)).UpdateRoleAsync(700, new UpdateUserRoleRequest { RoleId = ApproverRole }); // A fresh request for the same login now carries the new role. Assert.Equal(ApproverRole.ToString(), Claim(await Transformer(db).TransformAsync(Principal(auth)), AuthHexClaims.ErpRoleId)); } [Fact] public async Task TheLastActiveAdministrator_CannotBeDemotedOrDeactivated() { await using var db = await SeedAsync(); db.Users.Add(NewUser(Guid.NewGuid(), AdminRole, id: 801)); await db.SaveChangesAsync(); var svc = UserSvc(db, new FakeUser(userId: 1, roleId: AdminRole, isAdmin: true)); var demote = await Assert.ThrowsAsync(() => svc.UpdateRoleAsync(801, new UpdateUserRoleRequest { RoleId = ViewerRole })); var deactivate = await Assert.ThrowsAsync(() => svc.SetStatusAsync(801, EntityStatus.Inactive)); Assert.Equal(409, demote.StatusCode); Assert.Equal(409, deactivate.StatusCode); Assert.Equal(AdminRole, db.Users.Single(u => u.UserId == 801).RoleId); } [Fact] public async Task AnAdministrator_CanBeDemoted_WhenAnotherActiveAdminRemains() { await using var db = await SeedAsync(); db.Users.AddRange(NewUser(Guid.NewGuid(), AdminRole, id: 801), NewUser(Guid.NewGuid(), AdminRole, id: 802)); await db.SaveChangesAsync(); await UserSvc(db, new FakeUser(userId: 802, roleId: AdminRole, isAdmin: true)) .UpdateRoleAsync(801, new UpdateUserRoleRequest { RoleId = ViewerRole }); Assert.Equal(ViewerRole, db.Users.Single(u => u.UserId == 801).RoleId); } [Fact] public async Task Nobody_CanRemoveTheirOwnAdminAccess_OrDeactivateThemselves() { await using var db = await SeedAsync(); db.Users.AddRange(NewUser(Guid.NewGuid(), AdminRole, id: 801), NewUser(Guid.NewGuid(), AdminRole, id: 802)); await db.SaveChangesAsync(); var svc = UserSvc(db, new FakeUser(userId: 801, roleId: AdminRole, isAdmin: true)); await Assert.ThrowsAsync(() => svc.UpdateRoleAsync(801, new UpdateUserRoleRequest { RoleId = ViewerRole })); await Assert.ThrowsAsync(() => svc.SetStatusAsync(801, EntityStatus.Inactive)); } [Fact] public async Task AUser_CannotBeGivenAnInactiveRole() { await using var db = await SeedAsync(); db.Users.Add(NewUser(Guid.NewGuid(), ViewerRole, id: 900)); await db.SaveChangesAsync(); await Assert.ThrowsAsync( () => UserSvc(db, new FakeUser(1, AdminRole, true)).UpdateRoleAsync(900, new UpdateUserRoleRequest { RoleId = InactiveRole })); } // ---------------------------------------------------------------- access config (default role) private static AccessConfigService AccessSvc(ErpDbContext db) => new(new Repository(db), new Repository(db), new FakeUser(), new PassThroughUnitOfWork(db)); [Fact] public async Task TheDefaultRole_CanNeverBeAnAdministratorOrAnInactiveRole() { await using var db = await SeedAsync(); var svc = AccessSvc(db); var etag = db.AccessConfig.Single().RowVersion; await Assert.ThrowsAsync(() => svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = AdminRole }, etag)); await Assert.ThrowsAsync(() => svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = InactiveRole }, etag)); await Assert.ThrowsAsync(() => svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = 999 }, etag)); Assert.Equal(NoAccessRole, db.AccessConfig.Single().DefaultRoleId); var changed = (await svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = ViewerRole }, etag)).Value; Assert.Equal(("Viewer", ViewerRole), (changed.DefaultRoleName, changed.DefaultRoleId)); } // ---------------------------------------------------------------- seeding [Fact] public async Task Seeder_EnsuresAnAdminRoleAndALeastPrivilegeDefault_AndIsIdempotent() { await using var db = CreateContext(); db.Database.EnsureCreated(); await DataSeeder.SeedAsync(db); await DataSeeder.SeedAsync(db); var admin = Assert.Single(db.Roles.Where(r => r.IsAdmin)); Assert.True(admin.IsSystemRole); Assert.Equal(db.Permissions.Count(), db.RolePermissions.Count(rp => rp.RoleId == admin.RoleId)); var noAccess = Assert.Single(db.Roles.Where(r => r.Code == DataSeeder.DefaultNoAccessRoleCode)); Assert.Empty(db.RolePermissions.Where(rp => rp.RoleId == noAccess.RoleId)); Assert.Equal(noAccess.RoleId, Assert.Single(db.AccessConfig).DefaultRoleId); } [Fact] public async Task Seeder_FlagsAnExistingRoleCalledAdmin_InsteadOfAddingASecond_AndKeepsAChosenDefault() { await using var db = CreateContext(); db.Database.EnsureCreated(); db.Roles.Add(new Role { RoleId = 50, Code = "ADMIN", Name = "Old admin", CreatedAt = DateTime.UtcNow }); db.Roles.Add(new Role { RoleId = 51, Code = "Reader", Name = "Reader", CreatedAt = DateTime.UtcNow }); await db.SaveChangesAsync(); await DataSeeder.SeedAsync(db); db.AccessConfig.Single().DefaultRoleId = 51; // an administrator's own choice await db.SaveChangesAsync(); await DataSeeder.SeedAsync(db); Assert.True(db.Roles.Single(r => r.RoleId == 50).IsAdmin); Assert.Equal(1, db.Roles.Count(r => r.IsAdmin)); Assert.Equal(51, db.AccessConfig.Single().DefaultRoleId); } }