using System.Reflection; using System.Security.Claims; using ERPCore.Controllers; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; using ERPCore.Dtos.Search; using ERPCore.Infra.Auth; using ERPCore.Infra.Persistence; using ERPCore.Repositories; using ERPCore.Services; using ERPCore.System.Errors; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Caching.Memory; using static ERPCore.Tests.TestSupport.BankDepositTestSupport; namespace ERPCore.Tests; /// /// Permission coverage (Phase B): every API endpoint that changes data sits behind a permission, every permission the /// controllers name actually exists in the seeded catalogue, HR data is gated, global search respects the role, /// state-changing cookie requests need the CSRF token, and a branch-less person can't read every branch. /// public class PermissionCoverageTests { // The seeded (HasData) rows live in the design-time model; the runtime model drops them. private static IEnumerable> Seed(ErpDbContext db) => Microsoft.EntityFrameworkCore.Infrastructure.AccessorExtensions.GetService(db) .Model.FindEntityType(typeof(T))!.GetSeedData(); // ---------------------------------------------------------------- controllers <-> permissions private static IEnumerable ApiControllers() => typeof(ApiControllerBase).Assembly.GetTypes() .Where(t => !t.IsAbstract && typeof(ApiControllerBase).IsAssignableFrom(t)); private static IEnumerable NavPolicyNames(MemberInfo member) => member.GetCustomAttributes(inherit: true) .Select(a => a.Policy) .Where(p => p is not null && p.StartsWith(NavPolicies.Prefix, StringComparison.Ordinal))!; private static bool IsReadOnly(MethodInfo m) => m.GetCustomAttributes().Any() && !m.GetCustomAttributes().OfType().Any(a => a is not HttpGetAttribute); /// Endpoints that deliberately sit behind the login door only: personal, or checked inside the service. private static readonly HashSet OpenByDesign = new() { nameof(AuthController), // sign-in, sessions, password: the person's own nameof(NavController), // "what may I see": the person's own permissions nameof(NotificationsController), // the person's own inbox nameof(SupportTicketsController), // anyone may raise a ticket; the service scopes by owner/admin nameof(TrackingController), // read-only serial/batch pickers used inside sales and stock screens nameof(SearchController), // gated per result type inside GlobalSearchService nameof(MetaController), }; [Fact] public void EveryDataChangingEndpoint_IsBehindAPermission() { var uncovered = new List(); foreach (var controller in ApiControllers().Where(c => !OpenByDesign.Contains(c.Name))) { var classCovered = NavPolicyNames(controller).Any(); foreach (var action in controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .Where(m => m.GetCustomAttributes().OfType().Any())) { if (IsReadOnly(action)) continue; if (action.GetCustomAttributes().Any()) continue; if (!classCovered && !NavPolicyNames(action).Any()) uncovered.Add($"{controller.Name}.{action.Name}"); } } Assert.True(uncovered.Count == 0, "Data-changing endpoints with no Nav permission: " + string.Join(", ", uncovered)); } [Fact] public void EveryControllerAboutSensitiveData_IsGatedAsAWhole() { // HR (salaries, bank accounts, loans, payslips) and the audit trail used to be open to every signed-in person. foreach (var name in new[] { "AttendanceBatchesController", "LeaveRequestsController", "PayrollRunsController", "PayslipsController", "HrReportsController", "EmployeesController", "SalaryComponentsController", "TaxSlabsController", "PayrollStatutorySettingsController", "AuditLogsController", }) { var type = typeof(ApiControllerBase).Assembly.GetTypes().Single(t => t.Name == name); Assert.True(NavPolicyNames(type).Any(), $"{name} has no class-level Nav permission."); } } [Fact] public void PayData_NeedsThePayrollPermission_OnTopOfEmployees() { var employees = typeof(ApiControllerBase).Assembly.GetTypes().Single(t => t.Name == "EmployeesController"); foreach (var action in new[] { "ListBankDetails", "GetSalaryStructureHistory", "ListLoans", "GetLoan", "CreateLoan", "CreateSalaryStructure", "ReplaceBankDetails" }) { var method = employees.GetMethod(action)!; Assert.Contains(NavPolicyNames(method), p => p.StartsWith(NavPolicies.Prefix + "hrm.payroll", StringComparison.Ordinal)); } var reports = typeof(ApiControllerBase).Assembly.GetTypes().Single(t => t.Name == "HrReportsController"); foreach (var action in new[] { "PayrollRegister", "SalaryHistory" }) Assert.Contains(NavPolicyNames(reports.GetMethod(action)!), p => p == NavPolicies.Prefix + "hrm.payroll"); } [Fact] public void EveryNavCodeTheControllersName_ExistsInTheSeededCatalogue() { // The old Nav:vendors policy named a code that no longer existed, so only administrators got through. using var db = CreateContext(); var seeded = Seed(db) .Select(d => ((string)d[nameof(Permission.Code)]!).Substring("NAV:".Length)) .ToHashSet(StringComparer.Ordinal); var missing = new SortedSet(); foreach (var controller in ApiControllers()) { var names = NavPolicyNames(controller).Concat( controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly).SelectMany(NavPolicyNames)); foreach (var name in names) { var rest = name[NavPolicies.Prefix.Length..]; var sep = rest.LastIndexOf(NavPolicies.ActionSeparator, StringComparison.Ordinal); var code = sep >= 0 && Enum.TryParse(rest[(sep + 1)..], out _) ? rest[..sep] : rest; // A module code is satisfied by the module itself or any screen inside it (HasAccessAsync does the same). if (!seeded.Contains(code) && !seeded.Any(s => s.StartsWith(code + ".", StringComparison.Ordinal))) missing.Add($"{controller.Name}: {code}"); } } Assert.True(missing.Count == 0, "Controllers name permissions that are not seeded: " + string.Join("; ", missing)); } [Fact] public void SeededCatalogue_HasNoDuplicateCodes_AndEverySubItemHasAPermission() { using var db = CreateContext(); var permissions = Seed(db).ToList(); var codes = permissions.Select(p => (string)p[nameof(Permission.Code)]!).ToList(); Assert.Equal(codes.Count, codes.Distinct().Count()); var subItems = Seed(db); var granted = permissions.Where(p => p[nameof(Permission.SubNavItemId)] is not null).Select(p => (int)p[nameof(Permission.SubNavItemId)]!).ToHashSet(); foreach (var sub in subItems) Assert.True(granted.Contains((int)sub[nameof(SubNavItem.SubNavItemId)]!), $"No permission row for sub item {sub[nameof(SubNavItem.Code)]}."); } [Theory] [InlineData("hrm")] [InlineData("hrm.employees")] [InlineData("hrm.payroll")] [InlineData("sales-returns")] [InlineData("settings.audit-logs")] public void NewPermissions_AreSeeded(string code) { using var db = CreateContext(); var codes = Seed(db).Select(d => (string)d[nameof(Permission.Code)]!); Assert.Contains("NAV:" + code, codes); } // ---------------------------------------------------------------- the policies actually decide private sealed class FakeUser(int? roleId, bool isAdmin = false, int? branchId = null, bool branching = true) : ICurrentUser { public int AuditUserId => 10; public int? RoleId => roleId; public string? RoleCode => null; public bool IsAdmin => isAdmin; public string UserId => "10"; public bool IsAuthenticated => true; public int? BranchId => branchId; public bool BranchingEnabled => branching; } private static async Task<(ErpDbContext Db, NavPermissionService Nav)> RolesAsync() { var db = CreateContext(); db.Roles.AddRange( new Role { RoleId = 1, Code = "Hr", Name = "HR officer", CreatedAt = DateTime.UtcNow }, new Role { RoleId = 2, Code = "Clerk", Name = "Clerk", CreatedAt = DateTime.UtcNow }); db.NavItems.Add(new NavItem { NavItemId = 100, Code = "hrm", Label = "HR" }); db.SubNavItems.AddRange( new SubNavItem { SubNavItemId = 101, NavItemId = 100, Code = "hrm.employees", Label = "Employees" }, new SubNavItem { SubNavItemId = 102, NavItemId = 100, Code = "hrm.payroll", Label = "Payroll" }); db.Permissions.AddRange( new Permission { PermissionId = 200, Code = "NAV:hrm.employees", SubNavItemId = 101 }, new Permission { PermissionId = 201, Code = "NAV:hrm.payroll", SubNavItemId = 102 }); db.RolePermissions.Add(new RolePermission { RoleId = 1, PermissionId = 200, Actions = RolePermissionAction.View | RolePermissionAction.Edit }); await db.SaveChangesAsync(); return (db, new NavPermissionService(new Repository(db), new Repository(db), new MemoryCache(new MemoryCacheOptions()))); } private static async Task PassesAsync(NavPermissionService nav, ICurrentUser user, string policyName) { var provider = new NavPolicyProvider(Microsoft.Extensions.Options.Options.Create(new AuthorizationOptions())); var policy = (await provider.GetPolicyAsync(policyName))!; var requirement = policy.Requirements.OfType().Single(); var context = new AuthorizationHandlerContext(new[] { requirement }, new ClaimsPrincipal(new ClaimsIdentity("test")), null); await new NavAuthorizationHandler(user, nav).HandleAsync(context); return context.HasSucceeded; } [Fact] public async Task HrOfficer_ReachesEmployees_ButNotPayData_AndAnAdministratorReachesAll() { var (db, nav) = await RolesAsync(); await using var _ = db; var hr = new FakeUser(roleId: 1); Assert.True(await PassesAsync(nav, hr, "Nav:hrm")); // the module, via a screen inside it Assert.True(await PassesAsync(nav, hr, "Nav:hrm.employees")); Assert.True(await PassesAsync(nav, hr, "Nav:hrm.employees:Edit")); Assert.False(await PassesAsync(nav, hr, "Nav:hrm.employees:Delete")); // not granted Assert.False(await PassesAsync(nav, hr, "Nav:hrm.payroll")); // salaries, loans, bank accounts Assert.False(await PassesAsync(nav, hr, "Nav:settings.audit-logs")); Assert.False(await PassesAsync(nav, new FakeUser(roleId: 2), "Nav:hrm")); Assert.False(await PassesAsync(nav, new FakeUser(roleId: null), "Nav:hrm")); Assert.True(await PassesAsync(nav, new FakeUser(roleId: 2, isAdmin: true), "Nav:hrm.payroll:Approve")); } // ---------------------------------------------------------------- global search follows the role [Fact] public async Task Search_OnlyReturnsKindsTheRoleMayOpen() { var (db, nav) = await RolesAsync(); await using var _ = db; db.Employees.Add(new Employee { EmployeeId = 1, EmployeeCode = "E-1", FullName = "Alice Perera", BranchId = Branch1 }); await db.SaveChangesAsync(); GlobalSearchService Search(ICurrentUser u) => new( new Repository(db), new Repository(db), new Repository(db), new Repository(db), u, nav); var hr = await Search(new FakeUser(roleId: 1)).SearchAsync("Alice"); Assert.Contains(hr, r => r.Type == GlobalSearchResultType.Employee); var clerk = await Search(new FakeUser(roleId: 2)).SearchAsync("Alice"); Assert.DoesNotContain(clerk, r => r.Type == GlobalSearchResultType.Employee); var admin = await Search(new FakeUser(roleId: 2, isAdmin: true)).SearchAsync("Alice"); Assert.Contains(admin, r => r.Type == GlobalSearchResultType.Employee); } // ---------------------------------------------------------------- CSRF on every cookie-authenticated mutation private static (CsrfProtectionFilter Filter, ActionExecutingContext Context) CsrfCall( string method, string? cookie = "token", string? header = null, bool sessionCookie = true, bool bearer = false, bool anonymousEndpoint = false, bool hasCsrfAttribute = false) { var http = new DefaultHttpContext(); http.Request.Method = method; var cookies = new List(); if (sessionCookie) cookies.Add("erp_at=jwt"); if (cookie is not null) cookies.Add($"XSRF-TOKEN={cookie}"); if (cookies.Count > 0) http.Request.Headers.Cookie = string.Join("; ", cookies); if (header is not null) http.Request.Headers["X-XSRF-TOKEN"] = header; if (bearer) http.Request.Headers.Authorization = "Bearer abc"; var metadata = new List(); if (anonymousEndpoint) metadata.Add(new AllowAnonymousAttribute()); if (hasCsrfAttribute) metadata.Add(new ValidateCsrfAttribute()); http.SetEndpoint(new Endpoint(null, new EndpointMetadataCollection(metadata), "test")); var actionContext = new ActionContext(http, new RouteData(), new ActionDescriptor()); var context = new ActionExecutingContext(actionContext, new List(), new Dictionary(), new object()); return (new CsrfProtectionFilter(), context); } private static async Task RunAsync(CsrfProtectionFilter filter, ActionExecutingContext context) { var ran = false; await filter.OnActionExecutionAsync(context, () => { ran = true; return Task.FromResult(new ActionExecutedContext(context, context.Filters, context.Controller)); }); return ran; } [Theory] [InlineData("POST")] [InlineData("PUT")] [InlineData("PATCH")] [InlineData("DELETE")] public async Task CookieRequest_WithoutTheCsrfHeader_IsRefused(string method) { var (filter, context) = CsrfCall(method, cookie: "token", header: null); var ex = await Assert.ThrowsAsync(() => RunAsync(filter, context)); Assert.Equal(ErrorCodes.CsrfTokenMismatch, ex.Code); Assert.Equal(403, ex.StatusCode); } [Fact] public async Task CookieRequest_WithAWrongOrMissingCsrfCookie_IsRefused() { var (f1, c1) = CsrfCall("POST", cookie: "token", header: "other"); await Assert.ThrowsAsync(() => RunAsync(f1, c1)); var (f2, c2) = CsrfCall("POST", cookie: null, header: "token"); await Assert.ThrowsAsync(() => RunAsync(f2, c2)); } [Fact] public async Task CookieRequest_WithMatchingCsrfHeader_Runs() { var (filter, context) = CsrfCall("DELETE", cookie: "token", header: "token"); Assert.True(await RunAsync(filter, context)); } [Theory] [InlineData("GET")] [InlineData("HEAD")] [InlineData("OPTIONS")] public async Task SafeMethods_AreNotChecked(string method) { var (filter, context) = CsrfCall(method, cookie: null); Assert.True(await RunAsync(filter, context)); } [Fact] public async Task BearerCallers_AnonymousEndpoints_AndRequestsWithoutASessionCookie_AreNotChecked() { var (f1, c1) = CsrfCall("POST", cookie: null, bearer: true); Assert.True(await RunAsync(f1, c1)); var (f2, c2) = CsrfCall("POST", cookie: null, anonymousEndpoint: true); Assert.True(await RunAsync(f2, c2)); var (f3, c3) = CsrfCall("POST", cookie: null, sessionCookie: false); Assert.True(await RunAsync(f3, c3)); } [Fact] public async Task ActionsThatCarryTheirOwnCsrfAttribute_AreNotCheckedTwice() { var (filter, context) = CsrfCall("POST", cookie: null, hasCsrfAttribute: true); Assert.True(await RunAsync(filter, context)); // the attribute itself enforces it } [Fact] public void TheCsrfFilter_IsRegisteredGlobally() { var program = File.ReadAllText(Path.Combine(FindBackendRoot(), "ERPCore", "Program.cs")); Assert.Contains("Filters.Add()", program); } private static string FindBackendRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "ERPCore"))) dir = dir.Parent; return dir?.FullName ?? throw new DirectoryNotFoundException("Backend root not found."); } // ---------------------------------------------------------------- a branch-less person can't read every branch [Fact] public void BranchContext_AdministratorsAreNeverScoped() { var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, isAdmin: true, branchId: 5)); Assert.False(ctx.IsActive); Assert.Null(ctx.BranchId); } [Fact] public void BranchContext_AnEmployeeIsScopedToTheirOwnBranch() { var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, branchId: 5)); Assert.True(ctx.IsActive); Assert.Equal(5, ctx.BranchId); } [Fact] public void BranchContext_APersonWithNoBranch_InABranchingDeployment_SeesNoBranchAtAll() { var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, branchId: null, branching: true)); Assert.True(ctx.IsActive); Assert.Equal(CurrentBranchContext.NoBranchId, ctx.BranchId); } [Fact] public void BranchContext_APersonWithNoBranch_WhenBranchingIsOff_IsNotScoped() { var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, branchId: null, branching: false)); Assert.False(ctx.IsActive); Assert.Null(ctx.BranchId); } [Fact] public async Task QueryFilters_HideEveryWarehouse_FromABranchlessNonAdmin_ButNotFromAnAdminOrWhenBranchingIsOff() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString("N")).Options; async Task CountFor(ICurrentUser user, bool seed) { await using var db = new ErpDbContext(options, user, new CurrentBranchContext(user)); if (seed) { db.Warehouses.AddRange( new Warehouse { WarehouseId = 1, Code = "W1", Name = "One", BranchId = 1 }, new Warehouse { WarehouseId = 2, Code = "W2", Name = "Two", BranchId = 2 }); await db.SaveChangesAsync(); } return db.Warehouses.Count(); } Assert.Equal(2, await CountFor(new FakeUser(1, isAdmin: true), seed: true)); Assert.Equal(1, await CountFor(new FakeUser(1, branchId: 2), seed: false)); Assert.Equal(0, await CountFor(new FakeUser(1, branchId: null, branching: true), seed: false)); Assert.Equal(2, await CountFor(new FakeUser(1, branchId: null, branching: false), seed: false)); } }