From e7b1f270a53b45f5ce9bd0aa20a80f56058d53ae Mon Sep 17 00:00:00 2001
From: Dhananjaya99 <152056742+ashanruu@users.noreply.github.com>
Date: Sun, 18 Oct 2026 12:46:31 +0530
Subject: [PATCH] feat: add vendor optional fields and configuration hooks
- 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.
---
Backend/ERPCore.Tests/AccessControlTests.cs | 512 +
.../ERPCore.Tests/CustomerFieldSwitchTests.cs | 158 +
Backend/ERPCore.Tests/DualApprovalTests.cs | 22 +-
.../ERPCore.Tests/PermissionCoverageTests.cs | 420 +
.../ProcurementAndOpeningBalanceTests.cs | 840 ++
.../NotificationConfigTestSupport.cs | 1 +
.../Controllers/AccessConfigController.cs | 40 +
.../Controllers/AuditLogsController.cs | 3 +
Backend/ERPCore/Controllers/AuthController.cs | 3 +-
Backend/ERPCore/Controllers/GrnsController.cs | 5 +-
.../Hrm/AttendanceBatchesController.cs | 8 +
.../Controllers/Hrm/BranchesController.cs | 6 +
.../Controllers/Hrm/DepartmentsController.cs | 7 +
.../Controllers/Hrm/DesignationsController.cs | 7 +
.../Controllers/Hrm/EmployeesController.cs | 24 +
.../Hrm/EmploymentTypesController.cs | 7 +
.../Hrm/HrDocumentTypesController.cs | 7 +
.../Controllers/Hrm/HrReportsController.cs | 5 +
.../Hrm/LeaveRequestsController.cs | 7 +
.../Controllers/Hrm/LeaveTypesController.cs | 7 +
.../Controllers/Hrm/PayrollRunsController.cs | 7 +
.../Hrm/PayrollStatutorySettingsController.cs | 3 +
.../Controllers/Hrm/PayslipsController.cs | 3 +
.../Hrm/SalaryComponentsController.cs | 7 +
.../Controllers/Hrm/TaxSlabsController.cs | 4 +
.../Controllers/Hrm/WorkShiftsController.cs | 7 +
.../Controllers/OpeningBalancesController.cs | 71 +
.../ProcurementConfigController.cs | 46 +
.../Controllers/PurchaseOrdersController.cs | 5 +-
.../Controllers/ReasonCodesController.cs | 3 +
.../Controllers/SalesReturnsController.cs | 6 +-
.../ERPCore/Controllers/UsersController.cs | 20 +-
.../ERPCore/Controllers/VendorsController.cs | 11 +-
.../ERPCore/Domain/Entities/AccessConfig.cs | 26 +
.../Domain/Entities/DocumentApproval.cs | 5 +-
.../Domain/Entities/OpeningBalanceRun.cs | 74 +
Backend/ERPCore/Domain/Entities/PoLine.cs | 5 +
.../Domain/Entities/ProcurementConfig.cs | 42 +
Backend/ERPCore/Domain/Entities/Role.cs | 21 +-
.../ERPCore/Domain/Entities/SalesConfig.cs | 25 +
Backend/ERPCore/Domain/Entities/Vendor.cs | 7 +
.../Domain/Enums/OpeningBalanceKind.cs | 12 +
.../Domain/Enums/PurchaseOrderStatus.cs | 9 +-
.../ERPCore/Domain/Enums/VendorContactMode.cs | 12 +
.../ERPCore/Dtos/Approvals/ApprovalDtos.cs | 5 +-
Backend/ERPCore/Dtos/Auth/AuthRoleDtos.cs | 28 -
Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs | 4 +-
.../ERPCore/Dtos/Config/AccessConfigDtos.cs | 12 +
.../Dtos/Config/ProcurementConfigDtos.cs | 24 +
.../ERPCore/Dtos/Config/SalesConfigDtos.cs | 12 +-
Backend/ERPCore/Dtos/Grn/GrnDtos.cs | 6 +-
.../OpeningBalances/OpeningBalanceDtos.cs | 71 +
.../Dtos/Procurement/PurchaseOrderDtos.cs | 12 +-
Backend/ERPCore/Dtos/Rbac/MeDtos.cs | 4 +-
Backend/ERPCore/Dtos/Rbac/RoleDtos.cs | 2 +-
Backend/ERPCore/Dtos/Users/UserDtos.cs | 7 +
Backend/ERPCore/Dtos/Vendors/VendorDtos.cs | 15 +-
.../Infra/Auth/AuthHex/AuthHexClient.cs | 24 +-
.../Infra/Auth/AuthHex/IAuthHexClient.cs | 8 -
Backend/ERPCore/Infra/Auth/AuthHexClaims.cs | 20 +
Backend/ERPCore/Infra/Auth/CurrentUser.cs | 23 +-
.../Infra/Auth/ICurrentBranchContext.cs | 26 +-
Backend/ERPCore/Infra/Auth/ICurrentUser.cs | 27 +-
.../ERPCore/Infra/Auth/JwtAuthExtensions.cs | 10 +-
.../ERPCore/Infra/Auth/NavAuthorization.cs | 10 +-
.../Auth/ShadowUserClaimsTransformation.cs | 154 +-
.../Infra/Auth/ValidateCsrfAttribute.cs | 48 +-
.../AccessConfigConfiguration.cs | 31 +
.../Configurations/ApprovalConfiguration.cs | 2 +-
.../Configurations/NavItemConfiguration.cs | 6 +-
.../OpeningBalanceConfiguration.cs | 67 +
.../Configurations/PermissionConfiguration.cs | 15 +-
.../ProcurementConfigConfiguration.cs | 42 +
.../PurchaseOrderConfiguration.cs | 1 +
.../Configurations/RoleConfiguration.cs | 3 +-
.../SalesConfigConfiguration.cs | 7 +
.../Configurations/SubNavItemConfiguration.cs | 14 +-
.../Configurations/VendorConfiguration.cs | 3 +
.../ERPCore/Infra/Persistence/DataSeeder.cs | 65 +
.../ERPCore/Infra/Persistence/ErpDbContext.cs | 6 +
...curementConfigAndVendorContact.Designer.cs | 10917 +++++++++++++++
...13_AddProcurementConfigAndVendorContact.cs | 88 +
...61017194128_AddPoLineTaxAmount.Designer.cs | 10923 +++++++++++++++
.../20261017194128_AddPoLineTaxAmount.cs | 35 +
...261017194653_AddPoApprovalsNav.Designer.cs | 10939 +++++++++++++++
.../20261017194653_AddPoApprovalsNav.cs | 52 +
...61017200125_AddOpeningBalances.Designer.cs | 11121 +++++++++++++++
.../20261017200125_AddOpeningBalances.cs | 170 +
...7201933_AddVendorFieldSwitches.Designer.cs | 11131 +++++++++++++++
.../20261017201933_AddVendorFieldSwitches.cs | 40 +
...20261017214857_RolesOwnedByErp.Designer.cs | 11183 +++++++++++++++
.../20261017214857_RolesOwnedByErp.cs | 129 +
...61017235430_PermissionCoverage.Designer.cs | 11331 +++++++++++++++
.../20261017235430_PermissionCoverage.cs | 195 +
...70404_AddCustomerFieldSwitches.Designer.cs | 11366 ++++++++++++++++
...20261018070404_AddCustomerFieldSwitches.cs | 95 +
.../Migrations/ErpDbContextModelSnapshot.cs | 519 +-
Backend/ERPCore/Program.cs | 11 +-
.../ERPCore/Services/AccessConfigService.cs | 79 +
.../ERPCore/Services/ApprovalInboxService.cs | 2 +-
Backend/ERPCore/Services/ApprovalService.cs | 48 +-
.../ERPCore/Services/Auth/AuthUserService.cs | 3 +
.../ERPCore/Services/BankDepositService.cs | 5 +
Backend/ERPCore/Services/CustomerService.cs | 69 +-
.../ERPCore/Services/GeneralLedgerService.cs | 48 +-
Backend/ERPCore/Services/Gl/GlModels.cs | 2 +
.../ERPCore/Services/GlobalSearchService.cs | 24 +-
Backend/ERPCore/Services/GrnService.cs | 97 +-
.../Interfaces/IAccessConfigService.cs | 12 +
.../Interfaces/IFifoCostingService.cs | 8 +-
.../Services/Interfaces/IGrnService.cs | 3 +-
.../Interfaces/INavPermissionService.cs | 32 +-
.../Interfaces/IOpeningBalanceService.cs | 17 +
.../Interfaces/IOpeningStockService.cs | 17 +
.../Interfaces/IProcurementConfigService.cs | 16 +
.../Interfaces/IPurchaseOrderService.cs | 7 +-
.../Services/Interfaces/IRoleService.cs | 3 +-
.../Interfaces/IUserManagementService.cs | 10 +-
.../ERPCore/Services/NavPermissionService.cs | 99 +-
.../ERPCore/Services/OpeningBalanceService.cs | 178 +
.../ERPCore/Services/OpeningEquityAccount.cs | 61 +
.../ERPCore/Services/OpeningStockService.cs | 238 +
.../Services/ProcurementConfigService.cs | 78 +
.../ERPCore/Services/PurchaseOrderService.cs | 130 +-
.../ERPCore/Services/PurchaseReturnService.cs | 3 +-
Backend/ERPCore/Services/RoleService.cs | 125 +-
.../ERPCore/Services/SalesConfigService.cs | 11 +-
.../ERPCore/Services/SalesPostingService.cs | 3 +-
.../Services/Stock/FifoCostingService.cs | 4 +-
.../ERPCore/Services/Stock/StockMutator.cs | 6 +-
.../ERPCore/Services/UserManagementService.cs | 101 +-
Backend/ERPCore/Services/VendorService.cs | 81 +-
Backend/ERPCore/System/Errors/ErrorCodes.cs | 3 +
Backend/ERPCore/System/Time/BusinessClock.cs | 4 +
Backend/PROGRESS.md | 52 +
Backend/smoke/smoke_common.py | 3 +
.../accounts/opening-balances/page.tsx | 49 +
.../app/dashboard/accounts/page.tsx | 8 +-
.../app/dashboard/customers/[id]/page.tsx | 83 +-
.../app/dashboard/customers/page.tsx | 148 +-
.../app/dashboard/hrm/employees/[id]/page.tsx | 7 +-
Frontend/erp-system/app/dashboard/layout.tsx | 3 +-
.../procurement/purchase-orders/[id]/page.tsx | 194 +-
.../purchase-orders/approvals/page.tsx | 215 +-
.../procurement/purchase-orders/new/page.tsx | 272 +-
.../procurement/purchase-orders/page.tsx | 511 +-
.../app/dashboard/products/[id]/edit/page.tsx | 7 +
.../app/dashboard/products/[id]/page.tsx | 504 +-
.../app/dashboard/products/new/page.tsx | 2 +-
.../app/dashboard/products/page.tsx | 4 +-
.../products/price-adjustments/new/page.tsx | 24 +-
.../app/dashboard/receiving/grn/new/page.tsx | 60 +-
.../app/dashboard/receiving/grn/page.tsx | 525 +-
.../app/dashboard/sales/day-end/page.tsx | 18 +-
.../app/dashboard/sales/invoices/page.tsx | 202 +-
.../app/dashboard/settings/access/page.tsx | 123 +
.../app/dashboard/settings/approvals/page.tsx | 43 +-
.../dashboard/settings/audit-logs/page.tsx | 139 +
.../dashboard/settings/configuration/page.tsx | 14 +-
.../procurement-configuration/page.tsx | 213 +
.../settings/sales-configuration/page.tsx | 48 +
.../dashboard/settings/users/[id]/page.tsx | 62 +-
.../app/dashboard/settings/users/page.tsx | 2 +-
.../app/dashboard/vendors/[id]/page.tsx | 45 +-
.../erp-system/app/dashboard/vendors/page.tsx | 62 +-
.../components/Layouts/AppSidebar.tsx | 25 +-
.../erp-system/components/Layouts/Header.tsx | 1 +
.../accounts/OpeningCashBankPanel.tsx | 278 +
.../components/accounts/OpeningStockPanel.tsx | 449 +
.../erp-system/components/auth/AccessGate.tsx | 79 +
.../components/auth/AuthProvider.tsx | 111 +-
.../customers/EditCustomerDialog.tsx | 101 +-
.../procurement/PurchaseOrderListView.tsx | 395 +
.../components/procurement/status-badges.tsx | 15 +-
.../components/products/WarrantyFields.tsx | 183 +
.../components/receiving/GrnReceiptPrint.tsx | 301 +
.../components/ui/date-range-filter.tsx | 83 +
.../components/vendors/EditVendorDialog.tsx | 45 +-
.../vendors/VendorOptionalFields.tsx | 117 +
.../erp-system/hooks/use-customer-config.ts | 63 +
.../hooks/use-procurement-config.ts | 43 +
Frontend/erp-system/lib/api-client.ts | 22 +-
Frontend/erp-system/lib/api/access-config.ts | 21 +
Frontend/erp-system/lib/api/grns.ts | 5 +
.../erp-system/lib/api/opening-balances.ts | 40 +
.../erp-system/lib/api/procurement-config.ts | 22 +
.../erp-system/lib/api/purchase-orders.ts | 9 +-
Frontend/erp-system/lib/api/users.ts | 2 +
Frontend/erp-system/lib/nav-routes.ts | 42 +
.../lib/pdf/purchase-order-download.ts | 36 +
.../erp-system/lib/pdf/purchase-order-pdf.ts | 259 +
Frontend/erp-system/lib/po-tax.ts | 33 +
.../erp-system/lib/validations/procurement.ts | 1 +
Frontend/erp-system/types/approvals.ts | 4 +-
Frontend/erp-system/types/grn.ts | 7 +
Frontend/erp-system/types/master-data.ts | 8 +
Frontend/erp-system/types/opening-balances.ts | 78 +
Frontend/erp-system/types/procurement.ts | 40 +
Frontend/erp-system/types/rbac.ts | 8 +
Frontend/erp-system/types/sales.ts | 15 +
Testing/e2e/specs/global.setup.ts | 3 +
docs/02-SECURITY.md | 8 +-
202 files changed, 99906 insertions(+), 1894 deletions(-)
create mode 100644 Backend/ERPCore.Tests/AccessControlTests.cs
create mode 100644 Backend/ERPCore.Tests/CustomerFieldSwitchTests.cs
create mode 100644 Backend/ERPCore.Tests/PermissionCoverageTests.cs
create mode 100644 Backend/ERPCore.Tests/ProcurementAndOpeningBalanceTests.cs
create mode 100644 Backend/ERPCore/Controllers/AccessConfigController.cs
create mode 100644 Backend/ERPCore/Controllers/OpeningBalancesController.cs
create mode 100644 Backend/ERPCore/Controllers/ProcurementConfigController.cs
create mode 100644 Backend/ERPCore/Domain/Entities/AccessConfig.cs
create mode 100644 Backend/ERPCore/Domain/Entities/OpeningBalanceRun.cs
create mode 100644 Backend/ERPCore/Domain/Entities/ProcurementConfig.cs
create mode 100644 Backend/ERPCore/Domain/Enums/OpeningBalanceKind.cs
create mode 100644 Backend/ERPCore/Domain/Enums/VendorContactMode.cs
delete mode 100644 Backend/ERPCore/Dtos/Auth/AuthRoleDtos.cs
create mode 100644 Backend/ERPCore/Dtos/Config/AccessConfigDtos.cs
create mode 100644 Backend/ERPCore/Dtos/Config/ProcurementConfigDtos.cs
create mode 100644 Backend/ERPCore/Dtos/OpeningBalances/OpeningBalanceDtos.cs
create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/AccessConfigConfiguration.cs
create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/OpeningBalanceConfiguration.cs
create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/ProcurementConfigConfiguration.cs
create mode 100644 Backend/ERPCore/Migrations/20261017193713_AddProcurementConfigAndVendorContact.Designer.cs
create mode 100644 Backend/ERPCore/Migrations/20261017193713_AddProcurementConfigAndVendorContact.cs
create mode 100644 Backend/ERPCore/Migrations/20261017194128_AddPoLineTaxAmount.Designer.cs
create mode 100644 Backend/ERPCore/Migrations/20261017194128_AddPoLineTaxAmount.cs
create mode 100644 Backend/ERPCore/Migrations/20261017194653_AddPoApprovalsNav.Designer.cs
create mode 100644 Backend/ERPCore/Migrations/20261017194653_AddPoApprovalsNav.cs
create mode 100644 Backend/ERPCore/Migrations/20261017200125_AddOpeningBalances.Designer.cs
create mode 100644 Backend/ERPCore/Migrations/20261017200125_AddOpeningBalances.cs
create mode 100644 Backend/ERPCore/Migrations/20261017201933_AddVendorFieldSwitches.Designer.cs
create mode 100644 Backend/ERPCore/Migrations/20261017201933_AddVendorFieldSwitches.cs
create mode 100644 Backend/ERPCore/Migrations/20261017214857_RolesOwnedByErp.Designer.cs
create mode 100644 Backend/ERPCore/Migrations/20261017214857_RolesOwnedByErp.cs
create mode 100644 Backend/ERPCore/Migrations/20261017235430_PermissionCoverage.Designer.cs
create mode 100644 Backend/ERPCore/Migrations/20261017235430_PermissionCoverage.cs
create mode 100644 Backend/ERPCore/Migrations/20261018070404_AddCustomerFieldSwitches.Designer.cs
create mode 100644 Backend/ERPCore/Migrations/20261018070404_AddCustomerFieldSwitches.cs
create mode 100644 Backend/ERPCore/Services/AccessConfigService.cs
create mode 100644 Backend/ERPCore/Services/Interfaces/IAccessConfigService.cs
create mode 100644 Backend/ERPCore/Services/Interfaces/IOpeningBalanceService.cs
create mode 100644 Backend/ERPCore/Services/Interfaces/IOpeningStockService.cs
create mode 100644 Backend/ERPCore/Services/Interfaces/IProcurementConfigService.cs
create mode 100644 Backend/ERPCore/Services/OpeningBalanceService.cs
create mode 100644 Backend/ERPCore/Services/OpeningEquityAccount.cs
create mode 100644 Backend/ERPCore/Services/OpeningStockService.cs
create mode 100644 Backend/ERPCore/Services/ProcurementConfigService.cs
create mode 100644 Frontend/erp-system/app/dashboard/accounts/opening-balances/page.tsx
create mode 100644 Frontend/erp-system/app/dashboard/products/[id]/edit/page.tsx
create mode 100644 Frontend/erp-system/app/dashboard/settings/access/page.tsx
create mode 100644 Frontend/erp-system/app/dashboard/settings/audit-logs/page.tsx
create mode 100644 Frontend/erp-system/app/dashboard/settings/procurement-configuration/page.tsx
create mode 100644 Frontend/erp-system/components/accounts/OpeningCashBankPanel.tsx
create mode 100644 Frontend/erp-system/components/accounts/OpeningStockPanel.tsx
create mode 100644 Frontend/erp-system/components/auth/AccessGate.tsx
create mode 100644 Frontend/erp-system/components/procurement/PurchaseOrderListView.tsx
create mode 100644 Frontend/erp-system/components/products/WarrantyFields.tsx
create mode 100644 Frontend/erp-system/components/receiving/GrnReceiptPrint.tsx
create mode 100644 Frontend/erp-system/components/ui/date-range-filter.tsx
create mode 100644 Frontend/erp-system/components/vendors/VendorOptionalFields.tsx
create mode 100644 Frontend/erp-system/hooks/use-customer-config.ts
create mode 100644 Frontend/erp-system/hooks/use-procurement-config.ts
create mode 100644 Frontend/erp-system/lib/api/access-config.ts
create mode 100644 Frontend/erp-system/lib/api/opening-balances.ts
create mode 100644 Frontend/erp-system/lib/api/procurement-config.ts
create mode 100644 Frontend/erp-system/lib/nav-routes.ts
create mode 100644 Frontend/erp-system/lib/pdf/purchase-order-download.ts
create mode 100644 Frontend/erp-system/lib/pdf/purchase-order-pdf.ts
create mode 100644 Frontend/erp-system/lib/po-tax.ts
create mode 100644 Frontend/erp-system/types/opening-balances.ts
diff --git a/Backend/ERPCore.Tests/AccessControlTests.cs b/Backend/ERPCore.Tests/AccessControlTests.cs
new file mode 100644
index 0000000..9479874
--- /dev/null
+++ b/Backend/ERPCore.Tests/AccessControlTests.cs
@@ -0,0 +1,512 @@
+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);
+ }
+}
diff --git a/Backend/ERPCore.Tests/CustomerFieldSwitchTests.cs b/Backend/ERPCore.Tests/CustomerFieldSwitchTests.cs
new file mode 100644
index 0000000..17b9e48
--- /dev/null
+++ b/Backend/ERPCore.Tests/CustomerFieldSwitchTests.cs
@@ -0,0 +1,158 @@
+using ERPCore.Domain.Entities;
+using ERPCore.Dtos.Customers;
+using ERPCore.Infra.Persistence;
+using ERPCore.Repositories;
+using ERPCore.Services;
+using ERPCore.System.Errors;
+using ERPCore.Tests.TestSupport;
+using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
+
+namespace ERPCore.Tests;
+
+///
+/// Sales Configuration decides which optional customer fields exist (phone, email, address, city, country, credit limit,
+/// credit days). The forms hide a switched-off field; the server refuses a new value for it and keeps what is stored.
+///
+public class CustomerFieldSwitchTests
+{
+ private static CustomerService Svc(ErpDbContext db) =>
+ new(new Repository(db), new Repository(db), new FakeBranchConfig(),
+ new Repository(db), new Repository(db), new Repository(db),
+ new PassThroughUnitOfWork(db));
+
+ private static async Task SwitchesAsync(ErpDbContext db, Action tweak)
+ {
+ var config = new SalesConfig { ConfigId = SalesConfig.SingletonId };
+ tweak(config);
+ db.SalesConfig.Add(config);
+ await db.SaveChangesAsync();
+ }
+
+ private static CreateCustomerRequest NewCustomer(Action? tweak = null)
+ {
+ var request = new CreateCustomerRequest { CustomerCode = "C-1", Name = "Nimal Perera", BranchId = Branch1 };
+ tweak?.Invoke(request);
+ return request;
+ }
+
+ private static UpdateCustomerRequest Same(Customer c, Action? tweak = null)
+ {
+ var request = new UpdateCustomerRequest
+ {
+ CustomerCode = c.CustomerCode, CustomerType = c.CustomerType, Name = c.Name, DisplayName = c.DisplayName,
+ Phone = c.Phone, Email = c.Email, AddressLine1 = c.AddressLine1, AddressLine2 = c.AddressLine2,
+ City = c.City, Country = c.Country, TaxRegistrationNo = c.TaxRegistrationNo,
+ CreditLimit = c.CreditLimit, CreditDays = c.CreditDays, BranchId = c.BranchId
+ };
+ tweak?.Invoke(request);
+ return request;
+ }
+
+ [Fact]
+ public async Task EveryFieldIsOn_WhenNothingIsConfigured_SoExistingBehaviourIsUnchanged()
+ {
+ await using var db = CreateContext(); // no sales_config row at all
+
+ var created = (await Svc(db).CreateAsync(NewCustomer(r =>
+ {
+ r.Phone = "0771234567"; r.Email = "a@b.lk"; r.AddressLine1 = "No. 12"; r.City = "Colombo";
+ r.Country = "Sri Lanka"; r.CreditLimit = 5000m; r.CreditDays = 30;
+ }))).Value;
+
+ Assert.Equal(("0771234567", "a@b.lk", "No. 12", "Colombo", "Sri Lanka"), (created.Phone, created.Email, created.AddressLine1, created.City, created.Country));
+ Assert.Equal((5000m, 30), (created.CreditLimit, created.CreditDays));
+ }
+
+ [Theory]
+ [InlineData("phone")]
+ [InlineData("email")]
+ [InlineData("address")]
+ [InlineData("city")]
+ [InlineData("country")]
+ [InlineData("creditLimit")]
+ [InlineData("creditDays")]
+ public async Task Create_ANewValueForASwitchedOffField_IsRefused(string field)
+ {
+ await using var db = CreateContext();
+ await SwitchesAsync(db, c =>
+ {
+ c.CustomerPhoneEnabled = field != "phone";
+ c.CustomerEmailEnabled = field != "email";
+ c.CustomerAddressEnabled = field != "address";
+ c.CustomerCityEnabled = field != "city";
+ c.CustomerCountryEnabled = field != "country";
+ c.CustomerCreditLimitEnabled = field != "creditLimit";
+ c.CustomerCreditDaysEnabled = field != "creditDays";
+ });
+
+ var ex = await Assert.ThrowsAsync(() => Svc(db).CreateAsync(NewCustomer(r =>
+ {
+ switch (field)
+ {
+ case "phone": r.Phone = "0771234567"; break;
+ case "email": r.Email = "a@b.lk"; break;
+ case "address": r.AddressLine2 = "Wellawatte"; break;
+ case "city": r.City = "Colombo"; break;
+ case "country": r.Country = "Sri Lanka"; break;
+ case "creditLimit": r.CreditLimit = 100m; break;
+ default: r.CreditDays = 7; break;
+ }
+ })));
+
+ Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
+ Assert.Equal(422, ex.StatusCode);
+ Assert.Empty(db.Customers.Where(c => c.CustomerCode == "C-1"));
+ }
+
+ [Fact]
+ public async Task Create_WithTheSwitchedOffFieldsLeftBlank_Works()
+ {
+ await using var db = CreateContext();
+ await SwitchesAsync(db, c =>
+ {
+ c.CustomerPhoneEnabled = c.CustomerEmailEnabled = c.CustomerAddressEnabled = false;
+ c.CustomerCityEnabled = c.CustomerCountryEnabled = false;
+ c.CustomerCreditLimitEnabled = c.CustomerCreditDaysEnabled = false;
+ });
+
+ var created = (await Svc(db).CreateAsync(NewCustomer())).Value;
+
+ Assert.Equal("Nimal Perera", created.Name);
+ Assert.Null(created.Phone);
+ Assert.Equal((0m, 0), (created.CreditLimit, created.CreditDays));
+ }
+
+ [Fact]
+ public async Task Update_KeepsAStoredValueWhoseFieldWasSwitchedOff_EvenWhenTheFormSendsItBlank()
+ {
+ await using var db = CreateContext();
+ var svc = Svc(db);
+ var created = (await svc.CreateAsync(NewCustomer(r => { r.Phone = "0771234567"; r.CreditLimit = 5000m; r.CreditDays = 30; }))).Value;
+
+ await SwitchesAsync(db, c => { c.CustomerPhoneEnabled = false; c.CustomerCreditLimitEnabled = false; c.CustomerCreditDaysEnabled = false; });
+ var stored = db.Customers.Single(c => c.CustomerId == created.CustomerId);
+
+ // The edit form no longer has the fields, so it sends blanks / zeros.
+ var updated = (await svc.UpdateAsync(created.CustomerId,
+ Same(stored, r => { r.Name = "Nimal P."; r.Phone = null; r.CreditLimit = 0m; r.CreditDays = 0; }), stored.RowVersion)).Value;
+
+ Assert.Equal("Nimal P.", updated.Name);
+ Assert.Equal("0771234567", updated.Phone);
+ Assert.Equal((5000m, 30), (updated.CreditLimit, updated.CreditDays));
+ }
+
+ [Fact]
+ public async Task Update_ADifferentValueForASwitchedOffField_IsRefused()
+ {
+ await using var db = CreateContext();
+ var svc = Svc(db);
+ var created = (await svc.CreateAsync(NewCustomer(r => r.CreditLimit = 5000m))).Value;
+ await SwitchesAsync(db, c => c.CustomerCreditLimitEnabled = false);
+ var stored = db.Customers.Single(c => c.CustomerId == created.CustomerId);
+
+ var ex = await Assert.ThrowsAsync(
+ () => svc.UpdateAsync(created.CustomerId, Same(stored, r => r.CreditLimit = 9999m), stored.RowVersion));
+
+ Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
+ }
+}
diff --git a/Backend/ERPCore.Tests/DualApprovalTests.cs b/Backend/ERPCore.Tests/DualApprovalTests.cs
index 638f4f0..209a274 100644
--- a/Backend/ERPCore.Tests/DualApprovalTests.cs
+++ b/Backend/ERPCore.Tests/DualApprovalTests.cs
@@ -141,7 +141,7 @@ public class DualApprovalTests
[Fact]
public async Task SecondApproval_CanBeLimitedToRoles()
{
- var h = await Harness.CreateAsync(level2Roles: "Approver");
+ var h = await Harness.CreateAsync(level2RoleIds: ApproverRoleId.ToString());
var id = (await h.Stock.CreateAsync(Request(-1))).AdjustmentId;
h.User.Be(ApproverA);
await h.Stock.ApproveAsync(id, null);
@@ -250,7 +250,7 @@ public class DualApprovalTests
public required AdjustmentService Stock { get; init; }
public required PriceAdjustmentService Prices { get; init; }
- public static async Task CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2Roles = null)
+ public static async Task CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2RoleIds = null)
{
var db = new ErpDbContext(
new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root).Options,
@@ -283,7 +283,7 @@ public class DualApprovalTests
db.ApprovalConfig.Add(new ApprovalConfig
{
ApprovalConfigId = ApprovalConfig.SingletonId, StockAdjustmentLevels = stockLevels,
- StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleCodes = level2Roles
+ StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleIds = level2RoleIds
});
await db.SaveChangesAsync();
@@ -292,7 +292,7 @@ public class DualApprovalTests
var notifications = new RecordingNotifications();
var approvals = new ApprovalService(
new Repository(db), new Repository(db), new Repository(db),
- new Repository(db), user, notifications, uow);
+ new Repository(db), new Repository(db), user, notifications, uow);
var gl = new FakeGeneralLedger();
var mutator = new RecordingMutator();
var numbers = new FakeNumberSequence();
@@ -308,8 +308,9 @@ public class DualApprovalTests
new SerialTrackingService(new Repository(db), new Repository(db), new Repository(db),
new Repository(db), user));
var prices = new PriceAdjustmentService(
- new Repository(db), new Repository- (db), new Repository(db),
- new Repository(db), new Repository(db), approvals, notifications, numbers, user, uow);
+ new Repository(db), new Repository(db), new Repository
- (db),
+ new Repository(db), new Repository(db), new Repository(db),
+ approvals, notifications, numbers, user, uow);
return new Harness { Db = db, User = user, Mutator = mutator, Gl = gl, Notifications = notifications, Stock = stock, Prices = prices };
}
@@ -323,7 +324,13 @@ public class DualApprovalTests
[Requester] = "Requester", [ApproverA] = "Approver", [ApproverB] = "Approver", [Clerk] = "Clerk"
};
+ private static readonly Dictionary RoleIds = new()
+ {
+ [Requester] = 1, [ApproverA] = ApproverRoleId, [ApproverB] = ApproverRoleId, [Clerk] = ClerkRoleId
+ };
+
public int AuditUserId { get; private set; } = userId;
+ public int? RoleId { get; private set; } = RoleIds[userId];
public string? RoleCode { get; private set; } = roleCode;
public string UserId => AuditUserId.ToString();
public bool IsAuthenticated => true;
@@ -333,6 +340,7 @@ public class DualApprovalTests
public void Be(int userId)
{
AuditUserId = userId;
+ RoleId = RoleIds[userId];
RoleCode = Roles[userId];
}
}
@@ -368,7 +376,7 @@ public class DualApprovalTests
=> throw new NotSupportedException();
public Task PostLedgerAsync(int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, string sourceDocType, int sourceDocId,
- DateTime createdAt, CancellationToken ct = default, decimal? valueOverride = null) => throw new NotSupportedException();
+ DateTime createdAt, CancellationToken ct = default, decimal? valueOverride = null, bool postGlJournal = true) => throw new NotSupportedException();
public Task GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default) => Task.FromResult(10m);
}
diff --git a/Backend/ERPCore.Tests/PermissionCoverageTests.cs b/Backend/ERPCore.Tests/PermissionCoverageTests.cs
new file mode 100644
index 0000000..0477b6f
--- /dev/null
+++ b/Backend/ERPCore.Tests/PermissionCoverageTests.cs
@@ -0,0 +1,420 @@
+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