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")]
[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(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