e7b1f270a5
- Implemented VendorOptionalFields component to handle optional vendor details (mobile, email, address) with validation. - Created useCustomerConfig hook to load optional customer field settings from Sales configuration. - Developed useProcurementConfig hook to manage Procurement configuration flags. - Introduced accessConfigApi for managing access configuration settings. - Added openingBalancesApi for handling opening balances operations. - Created procurementConfigApi for managing procurement configuration settings. - Implemented nav-routes for permission-based routing. - Developed PDF generation for purchase orders with dynamic vendor and item details. - Added purchase-order tax helpers for tax calculations and formatting. - Defined types for opening balances and related operations.
421 lines
20 KiB
C#
421 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")]
|
|
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));
|
|
}
|
|
}
|