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.
This commit is contained in:
Dhananjaya99
2026-10-18 12:46:31 +05:30
parent c27db06880
commit e7b1f270a5
202 changed files with 99906 additions and 1894 deletions
+512
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<ErpDbContext> 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<Claim> { 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<string, string?> { ["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<Role>(db), new Repository<RolePermission>(db), new MemoryCache(new MemoryCacheOptions()));
private static RoleService RoleSvc(ErpDbContext db, ICurrentUser user, NavPermissionService nav) =>
new(new Repository<Role>(db), new Repository<RolePermission>(db), new Repository<Permission>(db), new Repository<Employee>(db),
new Repository<User>(db), new Repository<AccessConfig>(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<User>(db), new Repository<Role>(db), new Repository<Employee>(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<DomainException>(() => roles.SetStatusAsync(AdminRole, EntityStatus.Inactive));
await Assert.ThrowsAsync<DomainException>(() => roles.DeleteAsync(AdminRole));
await Assert.ThrowsAsync<DomainException>(() => roles.DeleteAsync(NoAccessRole)); // system role and the default
await Assert.ThrowsAsync<DomainException>(() => 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<DomainException>(() => 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<DomainException>(() => svc.UpdateRoleAsync(801, new UpdateUserRoleRequest { RoleId = ViewerRole }));
var deactivate = await Assert.ThrowsAsync<DomainException>(() => 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<DomainException>(() => svc.UpdateRoleAsync(801, new UpdateUserRoleRequest { RoleId = ViewerRole }));
await Assert.ThrowsAsync<DomainException>(() => 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<DomainException>(
() => 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<AccessConfig>(db), new Repository<Role>(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<DomainException>(() => svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = AdminRole }, etag));
await Assert.ThrowsAsync<DomainException>(() => svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = InactiveRole }, etag));
await Assert.ThrowsAsync<NotFoundException>(() => 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);
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
public class CustomerFieldSwitchTests
{
private static CustomerService Svc(ErpDbContext db) =>
new(new Repository<Customer>(db), new Repository<Warehouse>(db), new FakeBranchConfig(),
new Repository<SalesConfig>(db), new Repository<SalesInvoice>(db), new Repository<SalesInvoicePayment>(db),
new PassThroughUnitOfWork(db));
private static async Task SwitchesAsync(ErpDbContext db, Action<SalesConfig> tweak)
{
var config = new SalesConfig { ConfigId = SalesConfig.SingletonId };
tweak(config);
db.SalesConfig.Add(config);
await db.SaveChangesAsync();
}
private static CreateCustomerRequest NewCustomer(Action<CreateCustomerRequest>? 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<UpdateCustomerRequest>? 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<DomainException>(() => 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<DomainException>(
() => svc.UpdateAsync(created.CustomerId, Same(stored, r => r.CreditLimit = 9999m), stored.RowVersion));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
}
}
+15 -7
View File
@@ -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<Harness> CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2Roles = null)
public static async Task<Harness> CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2RoleIds = null)
{
var db = new ErpDbContext(
new DbContextOptionsBuilder<ErpDbContext>().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<ApprovalConfig>(db), new Repository<DocumentApproval>(db), new Repository<User>(db),
new Repository<RolePermission>(db), user, notifications, uow);
new Repository<RolePermission>(db), new Repository<Role>(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<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
new Repository<ProductConfig>(db), user));
var prices = new PriceAdjustmentService(
new Repository<PriceAdjustment>(db), new Repository<Item>(db), new Repository<ItemPriceHistory>(db),
new Repository<User>(db), new Repository<ApprovalConfig>(db), approvals, notifications, numbers, user, uow);
new Repository<PriceAdjustment>(db), new Repository<PriceAdjustmentLine>(db), new Repository<Item>(db),
new Repository<ItemPriceHistory>(db), new Repository<User>(db), new Repository<ApprovalConfig>(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<int, int> 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<StockLedger> 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<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default) => Task.FromResult(10m);
}
@@ -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;
/// <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));
}
}
@@ -0,0 +1,840 @@
using System.Text.Json;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Notifications;
using ERPCore.Dtos.OpeningBalances;
using ERPCore.Dtos.Procurement;
using ERPCore.Dtos.Vendors;
using ERPCore.Infra.Gl;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>
/// Procurement configuration (vendor contact / address, PO tax) and the one-time opening balances.
/// Every rule here is also enforced by the UI, so these tests are about the server refusing what a stale
/// or hand-built request sends.
/// </summary>
public class ProcurementAndOpeningBalanceTests
{
// ---------------------------------------------------------------- helpers
private static async Task SeedProcurementConfigAsync(
ErpDbContext db, VendorContactMode mode = VendorContactMode.None, bool address = false, bool tax = false,
bool terms = true, bool taxReg = true)
{
db.ProcurementConfig.Add(new ProcurementConfig
{
ConfigId = ProcurementConfig.SingletonId,
VendorContactMode = mode,
VendorAddressEnabled = address,
PoTaxEnabled = tax,
VendorPaymentTermsEnabled = terms,
VendorTaxRegEnabled = taxReg
});
await db.SaveChangesAsync();
}
private static ProcurementConfigService ConfigService(ErpDbContext db) =>
new(new Repository<ProcurementConfig>(db), new TestCurrentUser(), new PassThroughUnitOfWork(db));
private static VendorService VendorSvc(ErpDbContext db) =>
new(new Repository<Vendor>(db), new FakeBranchConfig(), ConfigService(db), new PassThroughUnitOfWork(db));
private static CreateVendorRequest NewVendor(string code, Action<CreateVendorRequest>? tweak = null)
{
var request = new CreateVendorRequest { Code = code, Name = $"Vendor {code}", Currency = "LKR", BranchId = Branch1 };
tweak?.Invoke(request);
return request;
}
private sealed class NullNotifications : INotificationService
{
public Task RaiseAsync(string moduleKey, string title, string message, string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default) => Task.CompletedTask;
public Task NotifyUsersAsync(IEnumerable<int> userIds, string moduleKey, string title, string message, string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default) => Task.CompletedTask;
public Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default) => throw new NotSupportedException();
public Task MarkAllReadAsync(int userId, CancellationToken ct = default) => Task.CompletedTask;
}
// ---------------------------------------------------------------- vendors
[Fact]
public async Task Vendor_Mobile_IsRejected_WhileContactModeIsNone()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.None);
var ex = await Assert.ThrowsAsync<DomainException>(
() => VendorSvc(db).CreateAsync(NewVendor("V1", r => r.Mobile = "0771234567")));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
Assert.Empty(db.Vendors);
}
[Fact]
public async Task Vendor_Mobile_IsSaved_WhenContactModeIsMobile_AndEmailIsStillRefused()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.Mobile);
var created = (await VendorSvc(db).CreateAsync(NewVendor("V2", r => r.Mobile = " +94 77 123 4567 "))).Value;
Assert.Equal("+94 77 123 4567", created.Mobile);
Assert.Null(created.Email);
var ex = await Assert.ThrowsAsync<DomainException>(
() => VendorSvc(db).CreateAsync(NewVendor("V3", r => r.Email = "a@b.lk")));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
}
[Theory]
[InlineData("abc")]
[InlineData("12")]
public async Task Vendor_InvalidMobile_IsRejected(string mobile)
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.Mobile);
var ex = await Assert.ThrowsAsync<DomainException>(
() => VendorSvc(db).CreateAsync(NewVendor("V4", r => r.Mobile = mobile)));
Assert.Equal(ErrorCodes.Validation, ex.Code);
}
[Fact]
public async Task Vendor_Address_NeedsTheSwitchOn()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, address: false);
var off = await Assert.ThrowsAsync<DomainException>(
() => VendorSvc(db).CreateAsync(NewVendor("V5", r => r.Address = "12 Galle Road")));
Assert.Equal(ErrorCodes.ConfigDisabled, off.Code);
db.ProcurementConfig.Single().VendorAddressEnabled = true;
await db.SaveChangesAsync();
var created = (await VendorSvc(db).CreateAsync(NewVendor("V6", r => r.Address = "12 Galle Road"))).Value;
Assert.Equal("12 Galle Road", created.Address);
}
[Fact]
public async Task Vendor_Update_KeepsStoredContact_WhenTheSwitchIsOff()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.Email, address: true);
var created = (await VendorSvc(db).CreateAsync(NewVendor("V7", r =>
{
r.Email = "accounts@supplier.lk";
r.Address = "Colombo";
}))).Value;
// Contact mode switched off later: an update must not erase what was recorded.
db.ProcurementConfig.Single().VendorContactMode = VendorContactMode.None;
await db.SaveChangesAsync();
var tracked = db.Vendors.Single(v => v.VendorId == created.VendorId);
var updated = (await VendorSvc(db).UpdateAsync(
created.VendorId,
new UpdateVendorRequest { Code = "V7", Name = "Renamed", Currency = "LKR", BranchId = Branch1 },
tracked.RowVersion)).Value;
Assert.Equal("Renamed", updated.Name);
Assert.Equal("accounts@supplier.lk", db.Vendors.Single().Email);
}
[Fact]
public async Task Vendor_TermsAndTaxReg_AreOnByDefault_AndSaved()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db); // defaults: terms + tax registration on
var created = (await VendorSvc(db).CreateAsync(NewVendor("V8", r =>
{
r.Terms = " NET30 ";
r.TaxReg = "134567890-7000";
}))).Value;
Assert.Equal("NET30", created.Terms);
Assert.Equal("134567890-7000", created.TaxReg);
}
[Fact]
public async Task Vendor_TermsAndTaxReg_AreRejectedWhenTheirSwitchesAreOff()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, terms: false, taxReg: false);
var terms = await Assert.ThrowsAsync<DomainException>(() => VendorSvc(db).CreateAsync(NewVendor("V9", r => r.Terms = "NET30")));
var taxReg = await Assert.ThrowsAsync<DomainException>(() => VendorSvc(db).CreateAsync(NewVendor("V10", r => r.TaxReg = "123")));
Assert.Equal(ErrorCodes.ConfigDisabled, terms.Code);
Assert.Equal(ErrorCodes.ConfigDisabled, taxReg.Code);
Assert.Empty(db.Vendors);
// Blank is fine: the form simply doesn't send them.
var ok = (await VendorSvc(db).CreateAsync(NewVendor("V11"))).Value;
Assert.Null(ok.Terms);
}
[Fact]
public async Task Vendor_Update_KeepsStoredTermsAndTaxReg_WhenSwitchedOff()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
var created = (await VendorSvc(db).CreateAsync(NewVendor("V12", r =>
{
r.Terms = "NET45";
r.TaxReg = "TRN-1";
}))).Value;
var cfg = db.ProcurementConfig.Single();
cfg.VendorPaymentTermsEnabled = false;
cfg.VendorTaxRegEnabled = false;
await db.SaveChangesAsync();
var tracked = db.Vendors.Single(v => v.VendorId == created.VendorId);
var updated = (await VendorSvc(db).UpdateAsync(
created.VendorId,
new UpdateVendorRequest { Code = "V12", Name = "Renamed", Currency = "LKR", BranchId = Branch1 },
tracked.RowVersion)).Value;
Assert.Equal("Renamed", updated.Name);
Assert.Equal(("NET45", "TRN-1"), (db.Vendors.Single().Terms, db.Vendors.Single().TaxReg));
}
// ---------------------------------------------------------------- purchase-order tax
private static PurchaseOrderService PoSvc(ErpDbContext db) =>
new(new Repository<PurchaseOrder>(db), new Repository<Vendor>(db), new Repository<Requisition>(db),
new Repository<Item>(db), new Repository<Uom>(db), new Repository<Warehouse>(db),
new FakeNumberSequence(), new TestCurrentUser(), new NullNotifications(), new FakeBranchConfig(),
ConfigService(db), new PassThroughUnitOfWork(db));
private static async Task<int> SeedVendorAndItemAsync(ErpDbContext db)
{
db.Vendors.Add(new Vendor { VendorId = 1, Code = "V-PO", Name = "PO Vendor", BranchId = Branch1, Status = EntityStatus.Active });
db.Items.Add(new Item { ItemId = 1, Sku = "SKU-1", Name = "Cola 500ml", CategoryId = 1, BaseUomId = 1 });
await db.SaveChangesAsync();
return 1;
}
private static CreatePurchaseOrderRequest PoRequest(decimal taxRate) => new()
{
VendorId = 1,
BranchId = Branch1,
SaveAsDraft = true,
Lines = [new CreatePoLineInput { ItemId = 1, WarehouseId = Warehouse1, Qty = 10, UnitPrice = 100m, Tax = taxRate }]
};
[Fact]
public async Task Po_TaxOn_StoresTheLineTaxAmount_AndTotalsIncludeIt()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, tax: true);
await SeedVendorAndItemAsync(db);
var po = (await PoSvc(db).CreateAsync(PoRequest(0.18m))).Value;
var line = Assert.Single(po.Lines);
Assert.Equal(180m, line.TaxAmount);
Assert.Equal((1000m, 180m, 1180m), (po.Totals.SubTotal, po.Totals.Tax, po.Totals.GrandTotal));
Assert.Equal(180m, db.PoLines.Single().TaxAmount);
}
[Fact]
public async Task Po_TaxAmount_RoundsHalfUp_ToTwoDecimals()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, tax: true);
await SeedVendorAndItemAsync(db);
// 3 x 33.5 = 100.5; at 5% the tax is 5.025 -> 5.03 (half away from zero, not banker's rounding).
var request = PoRequest(0.05m);
request.Lines[0].Qty = 3;
request.Lines[0].UnitPrice = 33.5m;
var po = (await PoSvc(db).CreateAsync(request)).Value;
Assert.Equal(5.03m, Assert.Single(po.Lines).TaxAmount);
}
[Fact]
public async Task Po_TaxOff_RejectsANonZeroRate_ButAcceptsZero()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, tax: false);
await SeedVendorAndItemAsync(db);
var ex = await Assert.ThrowsAsync<DomainException>(() => PoSvc(db).CreateAsync(PoRequest(0.18m)));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
Assert.Empty(db.PurchaseOrders);
var po = (await PoSvc(db).CreateAsync(PoRequest(0m))).Value;
Assert.Equal(0m, po.Totals.Tax);
}
[Fact]
public async Task Po_CarriesVendorDetails_OnlyForTheEnabledFields()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.Email, address: false);
await SeedVendorAndItemAsync(db);
var vendor = db.Vendors.Single();
vendor.Mobile = "0771234567";
vendor.Email = "po@vendor.lk";
vendor.Address = "Colombo";
await db.SaveChangesAsync();
var po = (await PoSvc(db).CreateAsync(PoRequest(0m))).Value;
Assert.NotNull(po.Vendor);
Assert.Equal("PO Vendor", po.Vendor!.Name);
Assert.Equal("po@vendor.lk", po.Vendor.Email);
Assert.Null(po.Vendor.Mobile); // contact mode is Email
Assert.Null(po.Vendor.Address); // address switched off
}
// ---------------------------------------------------------------- purchase-order list: date filter + sort
/// <summary>Three POs from two vendors, created on known business days; returns their ids in creation order.</summary>
private static async Task<int[]> SeedListAsync(ErpDbContext db)
{
db.Vendors.AddRange(
new Vendor { VendorId = 1, Code = "V-B", Name = "Beta Traders", BranchId = Branch1, Status = EntityStatus.Active },
new Vendor { VendorId = 2, Code = "V-A", Name = "Alpha Foods", BranchId = Branch1, Status = EntityStatus.Active });
db.Items.Add(new Item { ItemId = 1, Sku = "SKU-1", Name = "Cola", CategoryId = 1, BaseUomId = 1 });
PurchaseOrder Po(int id, int vendor, DateTime createdUtc, decimal qty) => new()
{
PoId = id, DocNo = $"PO-{id}", VendorId = vendor, BranchId = Branch1, Status = PurchaseOrderStatus.Draft,
CreatedAt = createdUtc,
Lines = [new PoLine { ItemId = 1, WarehouseId = Warehouse1, Qty = qty, UnitPrice = 10m }]
};
db.PurchaseOrders.AddRange(
Po(1, 1, new DateTime(2026, 4, 1, 4, 0, 0, DateTimeKind.Utc), 5), // 1 Apr (Colombo 09:30), total 50
Po(2, 2, new DateTime(2026, 4, 1, 20, 0, 0, DateTimeKind.Utc), 30), // 2 Apr in Colombo (01:30), total 300
Po(3, 1, new DateTime(2026, 4, 10, 6, 0, 0, DateTimeKind.Utc), 10)); // 10 Apr, total 100
await db.SaveChangesAsync();
return [1, 2, 3];
}
private static async Task<List<int>> ListIds(PurchaseOrderService svc, string? sort = null, DateOnly? from = null, DateOnly? to = null)
{
var page = await svc.ListAsync(new ERPCore.Dtos.Common.PageQuery { Sort = sort, PageSize = 50 }, null, null, from, to);
return page.Items.Select(i => i.PoId).ToList();
}
[Fact]
public async Task PoList_DateRange_UsesTheBusinessDayOfTheCreatedTime_AndIsInclusive()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedListAsync(db);
var svc = PoSvc(db);
// PO 2 was created 20:00 UTC on 1 Apr = 2 Apr in Colombo, so a "1 Apr only" filter must not include it.
Assert.Equal([1], await ListIds(svc, from: new DateOnly(2026, 4, 1), to: new DateOnly(2026, 4, 1)));
Assert.Equal([2], await ListIds(svc, from: new DateOnly(2026, 4, 2), to: new DateOnly(2026, 4, 2)));
Assert.Equal([3, 2], await ListIds(svc, from: new DateOnly(2026, 4, 2), to: new DateOnly(2026, 4, 10)));
Assert.Equal([3, 2, 1], await ListIds(svc, to: new DateOnly(2026, 4, 30)));
Assert.Equal([3], await ListIds(svc, from: new DateOnly(2026, 4, 3)));
}
[Fact]
public async Task PoList_Sorts_ByCreatedVendorAndTotal_BothWays()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedListAsync(db);
var svc = PoSvc(db);
Assert.Equal([3, 2, 1], await ListIds(svc)); // default: newest first
Assert.Equal([1, 2, 3], await ListIds(svc, "created")); // oldest first
Assert.Equal([2, 3, 1], await ListIds(svc, "vendor")); // Alpha, then Beta (ties: newest first)
Assert.Equal([3, 1, 2], await ListIds(svc, "-vendor"));
Assert.Equal([2, 3, 1], await ListIds(svc, "-total")); // 300, 100, 50
Assert.Equal([1, 3, 2], await ListIds(svc, "total"));
Assert.Equal([3, 2, 1], await ListIds(svc, "nonsense")); // unknown key falls back to the default
}
[Fact]
public async Task PoList_Search_MatchesVendorNameAndCode()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedListAsync(db);
var svc = PoSvc(db);
var byName = await svc.ListAsync(new ERPCore.Dtos.Common.PageQuery { Q = "alpha", PageSize = 50 }, null, null);
var byCode = await svc.ListAsync(new ERPCore.Dtos.Common.PageQuery { Q = "V-B", PageSize = 50 }, null, null);
Assert.Equal([2], byName.Items.Select(i => i.PoId));
Assert.Equal([3, 1], byCode.Items.Select(i => i.PoId));
}
// ---------------------------------------------------------------- opening balances
/// <summary>A GL that knows two LKR cash/bank accounts, optionally the equity account, and can create it.</summary>
private sealed class OpeningGl : IGeneralLedgerService
{
public List<GlJournalEntryRequest> Posted { get; } = new();
public int AccountCreates { get; private set; }
public bool EquityExists { get; init; } = true;
public string BankCurrency { get; init; } = "LKR";
public Task<GeneralLedgerResponse> ForwardAsync(HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
{
AccountCreates++;
return Task.FromResult(new GeneralLedgerResponse
{
StatusCode = 201,
Body = """{"statusCode":201,"success":true,"message":"Account created successfully","data":{"accountCode":"3000"}}"""
});
}
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
{
Posted.Add(request);
return Task.FromResult(new GlJournalEntryResult($"JV-2026-{Posted.Count:000000}", true));
}
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct) => Task.FromResult(new GlPeriod(1, 1));
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<GlBankAccount>>(
[
new("Cash", 1, "Main Cash Account", null, "Petty Cash", "CASH-000001", 17, "1400-01-01", "LKR"),
new("Bank", 2, "BOC Current", "Bank of Ceylon", null, "0001234567", 18, "1000-01", BankCurrency)
]);
public Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<GlAccount>>(EquityExists
? [new("3000", "Opening Balance Equity", 3, true, true)]
: [new("1000", "Bank", 1, false, true)]);
}
private static OpeningBalanceService OpeningSvc(ErpDbContext db, OpeningGl gl) =>
new(new Repository<OpeningBalanceRun>(db), gl, new TestCurrentUser(), new PassThroughUnitOfWork(db));
private static PostCashBankOpeningRequest Opening(DateOnly date, params (string Code, decimal Amount)[] lines) => new()
{
CutoverDate = date,
Lines = lines.Select(l => new OpeningBalanceLineInput { GlAccountCode = l.Code, Amount = l.Amount }).ToList()
};
private static readonly DateOnly OpeningDate = new(2026, 4, 1);
[Fact]
public async Task Opening_PostsOneBalancedJournal_DebitingEachAccount_AndCreditingEquity()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var run = await OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 500m), ("1000-01", 1500m)));
var entry = Assert.Single(gl.Posted);
Assert.Equal("OPENING_BALANCE", entry.JournalType);
Assert.Equal("OPENING", entry.SourceModule);
Assert.Equal(OpeningDate, entry.EntryDate);
Assert.Equal(entry.Lines.Sum(l => l.DebitAmount), entry.Lines.Sum(l => l.CreditAmount));
Assert.Contains(entry.Lines, l => l is { AccountCode: "1400-01-01", DebitAmount: 500m, CreditAmount: 0m });
Assert.Contains(entry.Lines, l => l is { AccountCode: "1000-01", DebitAmount: 1500m, CreditAmount: 0m });
Assert.Contains(entry.Lines, l => l is { AccountCode: "3000", DebitAmount: 0m, CreditAmount: 2000m });
Assert.Equal(2000m, run.TotalAmount);
Assert.Equal("JV-2026-000001", run.GlJournalNo);
Assert.Equal(2, run.Lines.Count);
Assert.Equal(0, gl.AccountCreates); // the equity account already existed
}
[Fact]
public async Task Opening_CreatesTheEquityAccount_WhenTheChartHasNone()
{
await using var db = CreateContext();
var gl = new OpeningGl { EquityExists = false };
await OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 100m)));
Assert.Equal(1, gl.AccountCreates);
Assert.Contains(Assert.Single(gl.Posted).Lines, l => l.AccountCode == "3000" && l.CreditAmount == 100m);
}
[Fact]
public async Task Opening_SecondPosting_IsRefused_AndPostsNothingMore()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var svc = OpeningSvc(db, gl);
await svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 100m)));
var ex = await Assert.ThrowsAsync<DomainException>(
() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 999m))));
Assert.Equal(ErrorCodes.OpeningBalanceAlreadyPosted, ex.Code);
Assert.Equal(409, ex.StatusCode);
Assert.Single(gl.Posted);
Assert.Equal(100m, Assert.Single(db.OpeningBalanceRuns.Include(r => r.Lines)).Lines.Single().Amount);
}
[Fact]
public async Task Opening_Overdraft_IsCreditedToTheAccount_AndEquityTakesTheNet()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var run = await OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 300m), ("1000-01", -100m)));
var entry = Assert.Single(gl.Posted);
Assert.Contains(entry.Lines, l => l is { AccountCode: "1000-01", DebitAmount: 0m, CreditAmount: 100m });
Assert.Contains(entry.Lines, l => l is { AccountCode: "3000", DebitAmount: 0m, CreditAmount: 200m });
Assert.Equal(entry.Lines.Sum(l => l.DebitAmount), entry.Lines.Sum(l => l.CreditAmount));
Assert.Equal(200m, run.TotalAmount);
}
[Fact]
public async Task Opening_ShowsPostedAmounts_OnTheStateEndpoint()
{
await using var db = CreateContext();
var svc = OpeningSvc(db, new OpeningGl());
var before = await svc.GetCashBankAsync();
Assert.Null(before.Posted);
Assert.All(before.Accounts, a => Assert.Null(a.Amount));
await svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 250m)));
var after = await svc.GetCashBankAsync();
Assert.NotNull(after.Posted);
Assert.Equal(250m, after.Accounts.Single(a => a.GlAccountCode == "1400-01-01").Amount);
Assert.Null(after.Accounts.Single(a => a.GlAccountCode == "1000-01").Amount);
}
[Fact]
public async Task Opening_RejectsBadInput_WithoutPostingAnything()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var svc = OpeningSvc(db, gl);
// not a cash/bank account
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("9999", 10m))));
// more than 2 decimals
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 10.005m))));
// nothing but zeros
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 0m))));
// the same account twice
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 5m), ("1400-01-01", 6m))));
// in the future
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30), ("1400-01-01", 5m))));
Assert.Empty(gl.Posted);
Assert.Empty(db.OpeningBalanceRuns);
}
[Fact]
public async Task Opening_RefusesANonBaseCurrencyAccount()
{
await using var db = CreateContext();
var gl = new OpeningGl { BankCurrency = "USD" };
var ex = await Assert.ThrowsAsync<DomainException>(
() => OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1000-01", 100m))));
Assert.Equal(ErrorCodes.Validation, ex.Code);
Assert.Empty(gl.Posted);
}
// ---------------------------------------------------------------- opening stock
private static OpeningStockService StockSvc(ErpDbContext db, OpeningGl gl)
{
var glAccounts = new StubGlAccountConfigService((GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
var fifo = new FifoCostingService(
new Repository<StockLayer>(db), new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), db, gl, glAccounts);
return new OpeningStockService(
new Repository<OpeningBalanceRun>(db), new Repository<Item>(db), new Repository<Warehouse>(db),
new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), fifo, gl, glAccounts,
new TestCurrentUser(), new PassThroughUnitOfWork(db));
}
private static Item StockedItem(int id, Action<Item>? tweak = null)
{
var item = new Item
{
ItemId = id, Sku = $"SKU-{id}", Name = $"Item {id}", CategoryId = 1, BaseUomId = 1,
StockNature = StockNature.Stocked, TrackingMode = TrackingMode.None, Status = EntityStatus.Active
};
tweak?.Invoke(item);
return item;
}
private static PostOpeningStockRequest StockRequest(params (int Item, decimal Qty, decimal Cost)[] lines) => new()
{
CutoverDate = OpeningDate,
Lines = lines.Select(l => new OpeningStockLineInput { ItemId = l.Item, WarehouseId = Warehouse1, Qty = l.Qty, UnitCost = l.Cost }).ToList()
};
[Fact]
public async Task OpeningStock_CreatesLayerAndLedgerRow_AndPostsExactlyOneJournal()
{
await using var db = CreateContext();
db.Items.AddRange(StockedItem(1), StockedItem(2));
await db.SaveChangesAsync();
var gl = new OpeningGl();
var run = await StockSvc(db, gl).PostAsync(StockRequest((1, 24m, 12.5m), (2, 10m, 3m)));
Assert.Equal(330m, run.TotalValue);
Assert.Equal(2, run.LineCount);
var layer = db.StockLayers.Single(l => l.ItemId == 1);
Assert.Equal((24m, 24m, 12.5m), (layer.QtyReceived, layer.QtyRemaining, layer.UnitCost));
var row = db.StockLedger.Single(l => l.ItemId == 1);
Assert.Equal(("OPN", Direction.In, 24m, 300m, 24m), (row.SourceDocType, row.Direction, row.QtyBase, row.Value, row.RunningBalance));
Assert.Equal(run.OpeningBalanceRunId, row.SourceDocId);
// One journal in total — the regular stock path would have added a STOCK journal (Dr Inventory / Cr Clearing) per row.
var entry = Assert.Single(gl.Posted);
Assert.Equal(("OPENING", "OPENING_BALANCE"), (entry.SourceModule, entry.JournalType));
Assert.Collection(entry.Lines,
l => Assert.Equal(("1100", 330m, 0m), (l.AccountCode, l.DebitAmount, l.CreditAmount)),
l => Assert.Equal(("3000", 0m, 330m), (l.AccountCode, l.DebitAmount, l.CreditAmount)));
Assert.Equal(2, db.JournalEntryStubs.Count(j => j.SourceDocType == "OPN"));
}
[Fact]
public async Task OpeningStock_RefusesAnItemThatAlreadyHasMovements_InThatWarehouse()
{
await using var db = CreateContext();
db.Items.AddRange(StockedItem(1), StockedItem(2));
db.StockLedger.Add(new StockLedger { ItemId = 1, WarehouseId = Warehouse1, Direction = Direction.In, QtyBase = 5, UnitCost = 1, Value = 5, RunningBalance = 5, SourceDocType = "GRN", SourceDocId = 1 });
await db.SaveChangesAsync();
var gl = new OpeningGl();
var ex = await Assert.ThrowsAsync<DomainException>(() => StockSvc(db, gl).PostAsync(StockRequest((1, 3m, 2m), (2, 3m, 2m))));
Assert.Equal(409, ex.StatusCode);
Assert.Contains("SKU-1", ex.Message);
Assert.Empty(gl.Posted);
Assert.Empty(db.OpeningBalanceRuns);
Assert.Empty(db.StockLayers); // nothing at all was written, not even for the clean item
}
[Fact]
public async Task OpeningStock_CanBeLoadedInSeveralRuns_ForDifferentItems()
{
await using var db = CreateContext();
db.Items.AddRange(StockedItem(1), StockedItem(2));
await db.SaveChangesAsync();
var gl = new OpeningGl();
var svc = StockSvc(db, gl);
await svc.PostAsync(StockRequest((1, 5m, 2m)));
await svc.PostAsync(StockRequest((2, 7m, 4m)));
Assert.Equal(2, gl.Posted.Count);
Assert.Equal(2, (await svc.ListAsync()).Count);
// ...but the same item cannot be opened a second time.
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((1, 1m, 1m))));
}
[Fact]
public async Task OpeningStock_RefusesUnsuitableItemsAndBadNumbers()
{
await using var db = CreateContext();
db.Items.AddRange(
StockedItem(1, i => i.TrackingMode = TrackingMode.Serial),
StockedItem(2, i => i.TrackingMode = TrackingMode.Batch),
StockedItem(3, i => i.StockNature = StockNature.Service),
StockedItem(4, i => i.Status = EntityStatus.Inactive),
StockedItem(5));
await db.SaveChangesAsync();
var gl = new OpeningGl();
var svc = StockSvc(db, gl);
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((1, 1m, 1m)))); // serial
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((2, 1m, 1m)))); // batch
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((3, 1m, 1m)))); // service
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((4, 1m, 1m)))); // inactive
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((99, 1m, 1m)))); // unknown
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 0m, 1m)))); // zero qty
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 1m, -1m)))); // negative cost
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 1.00001m, 1m)))); // > 4 decimals
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 1m, 1m), (5, 2m, 1m)))); // duplicate
Assert.Empty(gl.Posted);
Assert.Empty(db.StockLayers);
}
[Fact]
public async Task OpeningStock_ZeroCostRun_PostsNoJournal()
{
await using var db = CreateContext();
db.Items.Add(StockedItem(1));
await db.SaveChangesAsync();
var gl = new OpeningGl();
var run = await StockSvc(db, gl).PostAsync(StockRequest((1, 10m, 0m)));
Assert.Equal(0m, run.TotalValue);
Assert.Null(run.GlJournalNo);
Assert.Empty(gl.Posted);
Assert.Equal(10m, db.StockLayers.Single().QtyRemaining);
}
// ---------------------------------------------------------------- purchase-order rejection
[Fact]
public async Task Po_Reject_ShowsRejected_NotDraftOrPending_AndCanBeRevisedAndResubmitted()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedVendorAndItemAsync(db);
var svc = PoSvc(db);
var po = (await svc.CreateAsync(PoRequest(0m))).Value;
await svc.SubmitAsync(po.PoId);
// Somebody else raised it: the approver can't be its creator.
db.PurchaseOrders.Single().CreatedBy = 999;
await db.SaveChangesAsync();
var rejected = await svc.RejectAsync(po.PoId, "Wrong price");
Assert.Equal(PurchaseOrderStatus.Rejected, rejected.Status);
// A rejected order is reopened like a draft: it can be sent for approval again...
var resubmitted = await svc.SubmitAsync(po.PoId);
Assert.Equal(PurchaseOrderStatus.PendingApproval, resubmitted.Status);
}
[Fact]
public async Task Po_Rejected_CanBeDeleted_ButAnApprovedOneCannot()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedVendorAndItemAsync(db);
var svc = PoSvc(db);
var po = (await svc.CreateAsync(PoRequest(0m))).Value;
await svc.SubmitAsync(po.PoId);
db.PurchaseOrders.Single().CreatedBy = 999;
await db.SaveChangesAsync();
await svc.RejectAsync(po.PoId, null);
await svc.DeleteAsync(po.PoId);
Assert.Empty(db.PurchaseOrders);
}
// ---------------------------------------------------------------- General Ledger payload
private sealed class CapturingGlClient(int status, string body) : IGeneralLedgerClient
{
public string? SentBody { get; private set; }
public async Task<GeneralLedgerResponse> SendAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? requestBody, CancellationToken ct)
{
if (requestBody is not null)
{
using var reader = new StreamReader(requestBody);
SentBody = await reader.ReadToEndAsync(ct);
}
return new GeneralLedgerResponse { StatusCode = status, ContentType = "application/json", Body = body };
}
}
private static GlJournalEntryRequest GrnJournal() => new()
{
PeriodId = 1,
EntryDate = new DateOnly(2026, 10, 18),
SourceModule = "GRN",
Reference = "GRN-1",
Lines = [new GlJournalEntryLineRequest("1100", 10m, 0m, "x"), new GlJournalEntryLineRequest("2100", 0m, 10m)]
};
[Fact]
public async Task GlPost_NeverSendsNulls_BecauseGlTreatsANullJournalTypeAsMissing()
{
// GL's request DTO has a non-nullable JournalType; an explicit "journalType": null is a 400 from its model
// validation. Only opening balances set one, so every other posting (GRN, sales, ...) must leave it out.
var client = new CapturingGlClient(201, "{\"statusCode\":201,\"success\":true,\"message\":\"ok\",\"data\":{\"journalNo\":\"J-1\",\"isPosted\":true}}");
var result = await new GeneralLedgerService(client).PostJournalEntryAsync(GrnJournal(), CancellationToken.None);
Assert.Equal("J-1", result.JournalNo);
Assert.DoesNotContain("journalType", client.SentBody!);
Assert.DoesNotContain("null", client.SentBody!);
}
[Fact]
public async Task GlPost_KeepsAJournalTypeWhenOneIsSet()
{
var client = new CapturingGlClient(201, "{\"statusCode\":201,\"success\":true,\"message\":\"ok\",\"data\":{\"journalNo\":\"J-1\",\"isPosted\":true}}");
var request = GrnJournal();
request.JournalType = "OPENING_BALANCE";
await new GeneralLedgerService(client).PostJournalEntryAsync(request, CancellationToken.None);
Assert.Contains("\"journalType\":\"OPENING_BALANCE\"", client.SentBody!);
}
[Fact]
public async Task GlPost_AValidationFailureFromGl_IsReportedWithItsReason_NotAsAGenericRejection()
{
var problem = "{\"title\":\"One or more validation errors occurred.\",\"status\":400,\"errors\":{\"JournalType\":[\"The JournalType field is required.\"]}}";
var client = new CapturingGlClient(400, problem);
var ex = await Assert.ThrowsAsync<DomainException>(
() => new GeneralLedgerService(client).PostJournalEntryAsync(GrnJournal(), CancellationToken.None));
Assert.Equal(ErrorCodes.GlRequestFailed, ex.Code);
Assert.Contains("JournalType", ex.Message);
Assert.Contains("required", ex.Message);
}
[Fact]
public async Task GlPost_GlsOwnMessage_StillWins()
{
var client = new CapturingGlClient(400, "{\"StatusCode\":400,\"Success\":false,\"Message\":\"Period is locked\",\"Data\":null}");
var ex = await Assert.ThrowsAsync<DomainException>(
() => new GeneralLedgerService(client).PostJournalEntryAsync(GrnJournal(), CancellationToken.None));
Assert.Equal("Period is locked", ex.Message);
}
// ---------------------------------------------------------------- stock movement vs. document journal
[Fact]
public async Task StockLedgerPost_AddsTheGenericStockJournal_OnlyWhenTheDocumentHasNoJournalOfItsOwn()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var glAccounts = new StubGlAccountConfigService((GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
var fifo = new FifoCostingService(
new Repository<StockLayer>(db), new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), db, gl, glAccounts);
var now = DateTime.UtcNow;
// A GRN / sales invoice / adjustment posts its own balanced journal: the ledger row must not add a second one,
// or the movement is counted twice in the General Ledger.
await fifo.PostLedgerAsync(1, Warehouse1, null, null, null, 1, Direction.In, 100m, 200m, 100m, "GRN", 1, now,
CancellationToken.None, postGlJournal: false);
await db.SaveChangesAsync();
Assert.Empty(gl.Posted);
Assert.Single(db.StockLedger);
// Production and transfers have no document journal, so they keep the generic one.
await fifo.PostLedgerAsync(1, Warehouse1, null, null, null, 1, Direction.In, 5m, 10m, 105m, "TRF", 1, now,
CancellationToken.None);
var journal = Assert.Single(gl.Posted);
Assert.Equal("STOCK", journal.SourceModule);
}
}
@@ -14,6 +14,7 @@ internal sealed class TestCurrentUser : ICurrentUser
// Unauthenticated, so no role claim and no branch scoping: the three members the
// AuthHex integration added to ICurrentUser.
public int? RoleId => null;
public string? RoleCode => null;
public bool IsAdmin => false;
public int? BranchId => null;