Files
ERP-core/Backend/ERPCore.Tests/PermissionCoverageTests.cs
T
Naveen 0a212ed1aa feat(dashboard): add date filter and stat groups components
- Implemented DashboardDateFilter component for selecting date ranges.
- Created DashboardStatGroups component to display various dashboard statistics.
- Added dashboard chart data processing logic to support new chart specifications.
- Introduced reusable Chart components for rendering different types of charts.
2026-10-21 14:57:54 +05:30

429 lines
20 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
public class PermissionCoverageTests
{
// The seeded (HasData) rows live in the design-time model; the runtime model drops them.
private static IEnumerable<IDictionary<string, object?>> Seed<T>(ErpDbContext db) =>
Microsoft.EntityFrameworkCore.Infrastructure.AccessorExtensions.GetService<IDesignTimeModel>(db)
.Model.FindEntityType(typeof(T))!.GetSeedData();
// ---------------------------------------------------------------- controllers <-> permissions
private static IEnumerable<Type> ApiControllers() =>
typeof(ApiControllerBase).Assembly.GetTypes()
.Where(t => !t.IsAbstract && typeof(ApiControllerBase).IsAssignableFrom(t));
private static IEnumerable<string> NavPolicyNames(MemberInfo member) =>
member.GetCustomAttributes<AuthorizeAttribute>(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<HttpGetAttribute>().Any() && !m.GetCustomAttributes().OfType<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>().Any(a => a is not HttpGetAttribute);
/// <summary>Endpoints that deliberately sit behind the login door only: personal, or checked inside the service.</summary>
private static readonly HashSet<string> 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<string>();
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<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>().Any()))
{
if (IsReadOnly(action)) continue;
if (action.GetCustomAttributes<AllowAnonymousAttribute>().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<Permission>(db)
.Select(d => ((string)d[nameof(Permission.Code)]!).Substring("NAV:".Length))
.ToHashSet(StringComparer.Ordinal);
var missing = new SortedSet<string>();
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<RolePermissionAction>(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<Permission>(db).ToList();
var codes = permissions.Select(p => (string)p[nameof(Permission.Code)]!).ToList();
Assert.Equal(codes.Count, codes.Distinct().Count());
var subItems = Seed<SubNavItem>(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")]
[InlineData("dashboard.stock")]
[InlineData("dashboard.procurement")]
[InlineData("dashboard.approvals")]
[InlineData("dashboard.sales")]
[InlineData("dashboard.accounts")]
[InlineData("dashboard.hrm")]
[InlineData("dashboard.production")]
[InlineData("dashboard.service")]
public void NewPermissions_AreSeeded(string code)
{
using var db = CreateContext();
var codes = Seed<Permission>(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<Role>(db), new Repository<RolePermission>(db), new MemoryCache(new MemoryCacheOptions())));
}
private static async Task<bool> 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<NavRequirement>().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<Item>(db), new Repository<PurchaseOrder>(db), new Repository<SalesSlip>(db), new Repository<Employee>(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<string>();
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<object>();
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<IFilterMetadata>(), new Dictionary<string, object?>(), new object());
return (new CsrfProtectionFilter(), context);
}
private static async Task<bool> 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<DomainException>(() => 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<DomainException>(() => RunAsync(f1, c1));
var (f2, c2) = CsrfCall("POST", cookie: null, header: "token");
await Assert.ThrowsAsync<DomainException>(() => 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<ERPCore.Infra.Auth.CsrfProtectionFilter>()", 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<ErpDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N")).Options;
async Task<int> 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));
}
}