diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs index 556feb1..53e23e8 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs @@ -2007,6 +2007,12 @@ public static class DbInitializer // Mở lại sau golive: gỡ prefix khỏi revoke + thêm lại vào InReviewScope. await RevokeTemporarilyHiddenModulesAsync(db, roleManager, logger); + // [S118 2026-07-14 — anh Kiệt FDC] CHẠY SAU revoke để THẮNG: NGOẠI-LỆ role Procurement + // (Phòng Cung ứng) — mở lại Danh mục (Master/Suppliers/Projects/Departments/Catalog*) RIÊNG + // cho PRO (cần quản lý + CÔNG BỐ NCC — Suppliers R+C+U). S92 "chỉ Admin thấy" GIỮ NGUYÊN cho + // MỌI role khác (seeder chỉ đụng role Procurement). Dashboard/Reports KHÔNG cấp (anh chốt bỏ). + await SeedProcurementMasterAccessAsync(db, roleManager, logger); + // [S65 2026-06-16] CHẠY SAU revoke để THẮNG: mở lại quyền XEM "Hồ sơ Nhân sự" // (+ root nhóm "Nhân sự") cho MỌI role — anh chốt public module Nhân sự cho // user thường tra cứu hồ sơ. Dashboard NS (Hrm_Dashboard) + 6 catalog @@ -2180,6 +2186,83 @@ public static class DbInitializer } } + // [S118 2026-07-14 — anh Kiệt FDC] NGOẠI-LỆ role Procurement (Phòng Cung ứng): mở lại quyền + // Danh mục (Master + Suppliers + Projects + Departments + Catalog*) RIÊNG cho role Procurement + // — anh chốt PRO cần quản lý + CÔNG BỐ NCC (Suppliers R+C+U; S113 publish cần Suppliers.Update). + // S92 "chỉ Admin thấy" GIỮ NGUYÊN cho MỌI role khác. CHẠY SAU RevokeTemporarilyHiddenModulesAsync + // để THẮNG revoke (mirror pattern S65/S69). Dashboard/Reports CỐ Ý KHÔNG cấp (anh chốt bỏ — + // Reports lộ tổng giá trị HĐ + top NCC/dự án theo giá trị, ngược S92; reviewer S118 bắt). + // - Suppliers: Read+Create+Update (quản lý + công bố NCC). KHÔNG Delete. + // - Master + Catalogs (group) + Projects + Departments + 4 Catalog leaf: Read-only. + // - UPGRADE-ONLY (mirror Hrm S65 / Office S69): row đã tồn tại (revoke vừa set false) → NÂNG + // cờ desired lên true. Row chưa có (DB mới) → tạo. KHÔNG hạ cờ + KHÔNG đụng role khác. + // Thu hồi khi cần: xóa call ở SeedAsync — revoke sẽ tự che Master/Suppliers lại lần seed kế. + private static async Task SeedProcurementMasterAccessAsync( + ApplicationDbContext db, RoleManager roleManager, ILogger logger) + { + var role = await roleManager.FindByNameAsync(AppRoles.Procurement); + if (role is null) + { + logger.LogWarning("SeedProcurementMasterAccessAsync: skip — Procurement role chưa seed."); + return; + } + + // (MenuKey, CanRead, CanCreate, CanUpdate) — CanDelete luôn giữ nguyên (KHÔNG cấp Delete). + var grants = new (string Key, bool R, bool C, bool U)[] + { + (MenuKeys.Master, true, false, false), + (MenuKeys.Suppliers, true, true, true), // quản lý + CÔNG BỐ NCC (S113 cần Update) + (MenuKeys.Projects, true, false, false), + (MenuKeys.Departments, true, false, false), + (MenuKeys.Catalogs, true, false, false), + (MenuKeys.CatalogUnits, true, false, false), + (MenuKeys.CatalogMaterials, true, false, false), + (MenuKeys.CatalogServices, true, false, false), + (MenuKeys.CatalogWorkItems, true, false, false), + }; + var keys = grants.Select(g => g.Key).ToArray(); + + var existing = (await db.Permissions + .Where(p => p.RoleId == role.Id && keys.Contains(p.MenuKey)) + .ToListAsync()) + .ToDictionary(p => p.MenuKey); + + var added = 0; + var upgraded = 0; + foreach (var g in grants) + { + if (existing.TryGetValue(g.Key, out var row)) + { + var changed = false; + if (g.R && !row.CanRead) { row.CanRead = true; changed = true; } + if (g.C && !row.CanCreate) { row.CanCreate = true; changed = true; } + if (g.U && !row.CanUpdate) { row.CanUpdate = true; changed = true; } + if (changed) upgraded++; + continue; + } + + db.Permissions.Add(new Permission + { + RoleId = role.Id, + MenuKey = g.Key, + CanRead = g.R, + CanCreate = g.C, + CanUpdate = g.U, + CanDelete = false, + }); + added++; + } + + if (added > 0 || upgraded > 0) + { + await db.SaveChangesAsync(); + logger.LogInformation( + "Seeded Procurement master access: {Added} added + {Upgraded} upgraded " + + "(Master/Suppliers/Projects/Departments/Catalog* — PRO exception to S92, S118)", + added, upgraded); + } + } + // [S65 2026-06-16] Mở quyền XEM (Read-only) "Hồ sơ Nhân sự" cho MỌI role: anh chốt // public module Nhân sự (trọng tâm Hồ sơ NS) để user thường tra cứu hồ sơ nhân sự. // CHẠY SAU RevokeTemporarilyHiddenModulesAsync (SeedAsync) để THẮNG revoke: revoke diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/ProcurementMasterAccessSeedTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/ProcurementMasterAccessSeedTests.cs new file mode 100644 index 0000000..9d44301 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/ProcurementMasterAccessSeedTests.cs @@ -0,0 +1,255 @@ +using System.Reflection; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using SolutionErp.Domain.Identity; +using SolutionErp.Infrastructure.Persistence; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Application; + +// [S118 2026-07-14 — anh Kiệt FDC] Regression test SECURITY cho seeder MỚI +// DbInitializer.SeedProcurementMasterAccessAsync (private static). Đây là thay đổi +// security-sensitive → guard thuộc-tính-an-ninh CỐT LÕI: grant KHÔNG rò sang role khác. +// +// Bối cảnh chain (SeedAsync, order thực tế): +// 1. RevokeTemporarilyHiddenModulesAsync (S58+S92) → set 4 cờ CRUD=false cho MỌI role +// non-Admin trên Master/Suppliers/Projects/Departments/Catalog*/Contracts/Ct_*/Hrm*/ +// Off*/Personal ("chỉ Admin thấy"). +// 2. SeedProcurementMasterAccessAsync (S118) → CHẠY NGAY SAU để THẮNG revoke, mở NGOẠI-LỆ +// CHỈ role Procurement (Phòng Cung ứng): +// - Suppliers = Read + Create + Update (quản lý + CÔNG BỐ NCC, S113). Delete=false. +// - Master/Projects/Departments/Catalogs/4-Catalog-leaf = Read-only. +// UPGRADE-ONLY: row đã có → nâng cờ desired lên true; row chưa có → insert; KHÔNG hạ cờ; +// KHÔNG cấp Delete. (Dashboard/Reports CỐ Ý bị gỡ khỏi seeder — KHÔNG test 2 key này.) +// +// Thuộc-tính an ninh test bảo vệ: +// T1 upgrade-path — Procurement sau revoke→seed có ĐÚNG ma trận grant (Suppliers R+C+U D=false; +// 8 key read-only chỉ R). Chứng seeder THẮNG revoke đúng phạm vi. +// T2 SCOPE ISOLATION (quan trọng nhất) — role non-Admin KHÁC (DeptManager, đứng RIÊNG) VẪN bị +// ẩn hết sau cùng chuỗi → grant CHỈ đụng Procurement, S92 giữ nguyên role khác. +// Kèm CONTRAST assert Procurement THẬT được nâng (chống vacuous-pass). +// T3 insert-path — DB mới (chưa có row, không revoke) → seeder INSERT 9 row; Delete=false (an ninh). +// T4 idempotent — chạy lần 2 → end-state bất biến + KHÔNG tạo row trùng + KHÔNG chạm role khác. +// +// Kỹ thuật: 2 method là `private static` (KHÔNG sửa prod đổi visibility) → gọi qua REFLECTION +// (BindingFlags.NonPublic|Static). Signature cả 2: (ApplicationDbContext, RoleManager, ILogger). +// Test ĐÚNG behavior code thật, KHÔNG re-implement predicate/grant-set. +// +// FK (PermissionConfiguration): Permission.MenuKey → MenuItem.Key (Cascade) + RoleId → Role +// (Cascade) → PHẢI seed MenuItem rows + Role TRƯỚC khi seed Permission (nếu không SQLite FK Error 19). +// Revoke skip nếu Admin role chưa seed → luôn seed Admin dù test không assert Admin. +public class ProcurementMasterAccessSeedTests +{ + // Suppliers = key DUY NHẤT được Read+Create+Update (quản lý + công bố NCC — S113). Delete=false. + private const string SuppliersKey = MenuKeys.Suppliers; + + // 8 key CHỈ đọc (Read-only) — seeder chỉ nâng CanRead. + private static readonly string[] ReadOnlyKeys = + { + MenuKeys.Master, + MenuKeys.Projects, + MenuKeys.Departments, + MenuKeys.Catalogs, + MenuKeys.CatalogUnits, + MenuKeys.CatalogMaterials, + MenuKeys.CatalogServices, + MenuKeys.CatalogWorkItems, + }; + + // Toàn bộ 9 key seeder đụng (Suppliers + 8 read-only). Dùng const/factory MenuKeys + // (KHÔNG hardcode string) để bắt đồng-bộ nếu key đổi. + private static readonly string[] AllGrantKeys = + ReadOnlyKeys.Append(SuppliersKey).ToArray(); + + // ── Reflection invoke private static (ApplicationDbContext, RoleManager, ILogger) ── + private static async Task InvokePrivateStaticAsync(IdentityFixture fix, string methodName) + { + var db = fix.Services.GetRequiredService(); + var rm = fix.Services.GetRequiredService>(); + var mi = typeof(DbInitializer).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static); + mi.Should().NotBeNull( + $"DbInitializer.{methodName} phải tồn tại (private static) — đổi signature thì cập nhật test"); + var task = (Task)mi!.Invoke(null, new object[] { db, rm, NullLogger.Instance })!; + await task; + } + + private static Task InvokeRevokeAsync(IdentityFixture fix) => + InvokePrivateStaticAsync(fix, "RevokeTemporarilyHiddenModulesAsync"); + + private static Task InvokeSeedProcurementAsync(IdentityFixture fix) => + InvokePrivateStaticAsync(fix, "SeedProcurementMasterAccessAsync"); + + // Seed roles: Admin (để Revoke KHÔNG skip) + Procurement (target grant) + DeptManager + // (control isolation — đứng RIÊNG, KHÔNG kèm Procurement). Trả (adminId, procurementId, otherId). + private static async Task<(Guid adminId, Guid procurementId, Guid otherId)> SeedRolesAsync(IdentityFixture fix) + { + var rm = fix.Services.GetRequiredService>(); + var admin = new Role { Id = Guid.NewGuid(), Name = AppRoles.Admin }; + var procurement = new Role { Id = Guid.NewGuid(), Name = AppRoles.Procurement }; + var other = new Role { Id = Guid.NewGuid(), Name = AppRoles.DeptManager }; + await rm.CreateAsync(admin); + await rm.CreateAsync(procurement); + await rm.CreateAsync(other); + return (admin.Id, procurement.Id, other.Id); + } + + // Seed MenuItem rows (FK target cho Permission.MenuKey — cả pre-grant lẫn seeder-insert cần). + private static async Task SeedMenuItemsAsync(TestApplicationDbContext db) + { + foreach (var key in AllGrantKeys) + db.MenuItems.Add(new MenuItem { Key = key, Label = key }); + await db.SaveChangesAsync(CancellationToken.None); + } + + private static void AddPerm( + TestApplicationDbContext db, Guid roleId, string menuKey, + bool canRead = false, bool canCreate = false, bool canUpdate = false, bool canDelete = false) + => db.Permissions.Add(new Permission + { + RoleId = roleId, + MenuKey = menuKey, + CanRead = canRead, + CanCreate = canCreate, + CanUpdate = canUpdate, + CanDelete = canDelete, + }); + + private static async Task GetPermAsync(TestApplicationDbContext db, Guid roleId, string menuKey) + => await db.Permissions.AsNoTracking() + .FirstOrDefaultAsync(p => p.RoleId == roleId && p.MenuKey == menuKey); + + private static async Task AssertFlagsAsync( + TestApplicationDbContext db, Guid roleId, string key, + bool r, bool c, bool u, bool d, string because) + { + var row = await GetPermAsync(db, roleId, key); + row.Should().NotBeNull($"{key}: {because}"); + row!.CanRead.Should().Be(r, $"{key}.CanRead — {because}"); + row.CanCreate.Should().Be(c, $"{key}.CanCreate — {because}"); + row.CanUpdate.Should().Be(u, $"{key}.CanUpdate — {because}"); + row.CanDelete.Should().Be(d, $"{key}.CanDelete — {because}"); + } + + // Ma trận grant kỳ vọng cho Procurement sau khi seeder chạy: Suppliers R+C+U (D=false), + // 8 key còn lại chỉ Read. Dùng chung cho T1/T2-contrast/T3/T4. + private static async Task AssertProcurementGrantMatrixAsync(TestApplicationDbContext db, Guid procurementId) + { + await AssertFlagsAsync(db, procurementId, SuppliersKey, + r: true, c: true, u: true, d: false, + "Suppliers phải R+C+U cho Procurement (quản lý + CÔNG BỐ NCC — S113), Delete=false"); + + foreach (var key in ReadOnlyKeys) + await AssertFlagsAsync(db, procurementId, key, + r: true, c: false, u: false, d: false, + "read-only grant — CHỈ CanRead, KHÔNG Create/Update/Delete"); + } + + // T1 — UPGRADE PATH (chain prod-realistic): Procurement từng có CanRead → revoke S92 hạ false + // → seeder S118 nâng lại đúng ma trận. Chứng seeder THẮNG revoke, đúng phạm vi cờ. + [Fact] + public async Task Procurement_AfterRevokeThenSeed_HasExactGrantMatrix() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var (_, procurementId, _) = await SeedRolesAsync(fix); + await SeedMenuItemsAsync(db); + + // Procurement từng có CanRead trên cả 9 key (grant cũ trước S92) → revoke có gì để hạ. + foreach (var key in AllGrantKeys) + AddPerm(db, procurementId, key, canRead: true); + await db.SaveChangesAsync(CancellationToken.None); + + await InvokeRevokeAsync(fix); // S92: 9 key CanRead=false cho non-Admin + await InvokeSeedProcurementAsync(fix); // S118: NGOẠI-LỆ Procurement — nâng lại + + await AssertProcurementGrantMatrixAsync(db, procurementId); + } + + // T2 — ⭐ SCOPE ISOLATION (thuộc-tính an ninh QUAN TRỌNG NHẤT): role non-Admin KHÁC + // (DeptManager, đứng RIÊNG — KHÔNG kèm Procurement) VẪN có Master/Suppliers CanRead=false + // sau cùng chuỗi seed → grant S118 CHỈ đụng Procurement, S92 "chỉ Admin thấy" giữ nguyên. + [Fact] + public async Task SeedProcurement_DoesNotLeakToOtherNonAdminRole() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var (_, procurementId, otherId) = await SeedRolesAsync(fix); + await SeedMenuItemsAsync(db); + + // CẢ Procurement + DeptManager từng có CanRead trên 9 key (grant cũ) → revoke hạ CẢ HAI. + foreach (var key in AllGrantKeys) + { + AddPerm(db, procurementId, key, canRead: true); + AddPerm(db, otherId, key, canRead: true); + } + await db.SaveChangesAsync(CancellationToken.None); + + await InvokeRevokeAsync(fix); // revoke CẢ HAI role về false + await InvokeSeedProcurementAsync(fix); // CHỈ nâng lại Procurement + + // ⭐ ISOLATION: DeptManager row TỒN TẠI (revoke giữ vết) NHƯNG mọi cờ = false — seeder + // Procurement KHÔNG chạm role khác. Assert cả Master + Suppliers (2 key task chốt) + 7 key kia. + foreach (var key in AllGrantKeys) + await AssertFlagsAsync(db, otherId, key, + r: false, c: false, u: false, d: false, + "DeptManager KHÔNG được Procurement-grant chạm — S92 'chỉ Admin thấy' giữ nguyên role khác"); + + // CONTRAST (chống vacuous-pass): Procurement THẬT SỰ được nâng → chứng seeder CÓ chạy, + // nên isolation ở trên KHÔNG phải "false vì seeder no-op". + await AssertProcurementGrantMatrixAsync(db, procurementId); + } + + // T3 — INSERT PATH (DB mới): chưa có Permission row + KHÔNG revoke → seeder phải INSERT 9 row. + // Chốt security: hàng insert KHÔNG cấp Delete (CanDelete=false trên cả 9). + [Fact] + public async Task SeedProcurement_InsertPath_FreshDb_CreatesRowsWithoutDelete() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var (_, procurementId, _) = await SeedRolesAsync(fix); + await SeedMenuItemsAsync(db); + // KHÔNG pre-grant + KHÔNG revoke → nhánh insert (db.Permissions.Add) của seeder. + + await InvokeSeedProcurementAsync(fix); + + await AssertProcurementGrantMatrixAsync(db, procurementId); + + var count = await db.Permissions.AsNoTracking().CountAsync(p => p.RoleId == procurementId); + count.Should().Be(AllGrantKeys.Length, "seeder insert đúng 9 row (Suppliers + 8 read-only), không dư"); + } + + // T4 — IDEMPOTENT: chạy seeder lần 2 → end-state bất biến + KHÔNG tạo row trùng + KHÔNG chạm role khác. + [Fact] + public async Task SeedProcurement_Idempotent_SecondRunNoChange() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var (_, procurementId, otherId) = await SeedRolesAsync(fix); + await SeedMenuItemsAsync(db); + + foreach (var key in AllGrantKeys) + { + AddPerm(db, procurementId, key, canRead: true); + AddPerm(db, otherId, key, canRead: true); + } + await db.SaveChangesAsync(CancellationToken.None); + + await InvokeRevokeAsync(fix); + await InvokeSeedProcurementAsync(fix); + await InvokeSeedProcurementAsync(fix); // ⭐ chạy lần 2 — end-state phải bất biến + + await AssertProcurementGrantMatrixAsync(db, procurementId); + + // Không nhân đôi row Procurement. + var procCount = await db.Permissions.AsNoTracking().CountAsync(p => p.RoleId == procurementId); + procCount.Should().Be(AllGrantKeys.Length, "idempotent — chạy lại KHÔNG tạo row trùng"); + + // Role khác vẫn bị ẩn sau lần 2 (isolation bền vững). + foreach (var key in AllGrantKeys) + await AssertFlagsAsync(db, otherId, key, + r: false, c: false, u: false, d: false, + "idempotent — lần seed thứ 2 KHÔNG rò sang DeptManager"); + } +}