diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs index f401a25..d8f13cf 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs @@ -1996,14 +1996,20 @@ public static class DbInitializer // (+ 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 // Hrm_Config* VẪN ẨN (revoke che). Read-only (chỉ CanRead). - await SeedAllRolesHrmProfileReadPermissionsAsync(db, roleManager, logger); + // [S92 2026-06-29] TẮT re-grant "Hồ sơ Nhân sự" public (S65) — anh chốt nhóm + // "Nhân sự" CHỈ Admin thấy. RevokeTemporarilyHiddenModulesAsync (chạy ngay trên, + // S92 mở rộng phủ thêm Contracts/Master/Catalogs) là bước permission CUỐI → thắng + // mọi grant. Bật lại module nào: bỏ comment call tương ứng + gỡ prefix khỏi revoke. + // await SeedAllRolesHrmProfileReadPermissionsAsync(db, roleManager, logger); // S65 — TẮT S92 // [S69 2026-06-17] GOLIVE Văn phòng số: mở Read+Create module Office (self-service) // cho MỌI role — anh chốt "public văn phòng số cho all user eoffice". CHẠY SAU revoke // để THẮNG (mirror S65). Allow-list 16 key (loại Off_PhongHop_Manage admin-CRUD + // Off_AttendanceReport báo-cáo-riêng-tư + Off_ChamCong Cá-nhân — giữ ẩn). HRM (trừ // Hồ sơ NS) + Personal VẪN ẩn (anh chỉ mở Office). - await SeedAllRolesOfficeModulePermissionsAsync(db, roleManager, logger); + // [S92 2026-06-29] TẮT re-grant "Văn phòng số" public (S69) — anh chốt nhóm + // "Văn phòng số" CHỈ Admin thấy. + // await SeedAllRolesOfficeModulePermissionsAsync(db, roleManager, logger); // S69 — TẮT S92 } // [S57] Cấp CanRead (CHỈ xem) cho MỌI role trên menu HRM + Office + Master để mọi @@ -2027,10 +2033,12 @@ public static class DbInitializer // số + Cá nhân với user thường (chưa golive các module này). Revoke tương ứng // ở RevokeTemporarilyHiddenModulesAsync (chạy sau). Loại trừ tự nhiên (không // match prefix): PeWf_* (ký tự thứ 3 'W' ≠ '_'), AwV2_*, Ct_*, Bg_*, Wf_*, System. - static bool InReviewScope(string key) => - key.StartsWith("Catalog") || key == MenuKeys.Master || - key == MenuKeys.Suppliers || key == MenuKeys.Projects || key == MenuKeys.Departments || - key.StartsWith("Pe_"); + // [S92 2026-06-29] Narrow: BỎ Master/Catalogs/Suppliers/Projects/Departments khỏi + // grant-all-roles (anh chốt "Danh mục" CHỈ Admin). GIỮ Pe_* (Duyệt NCC read+create + // cho mọi role — KHÔNG đụng). Master-data GET = any-auth (Suppliers/Projects/Catalogs + // controller class-[Authorize]) nên form PE vẫn chọn Dự án/NCC/Hạng mục dù menu ẩn. + // Row Master/Catalogs cũ trên prod được RevokeTemporarilyHiddenModulesAsync (dưới) gỡ. + static bool InReviewScope(string key) => key.StartsWith("Pe_"); // Phân biệt key Pe_* (read+create) vs read-only. Pe_* match cờ thứ-3 '_' // → "PeWf_*"/"PeWorkflows" KHÔNG match (loại admin Designer). @@ -2128,7 +2136,14 @@ public static class DbInitializer var rows = await db.Permissions .Where(p => p.RoleId != adminRoleId && (p.MenuKey.StartsWith("Hrm") || p.MenuKey.StartsWith("Off") - || p.MenuKey == MenuKeys.Personal) + || p.MenuKey == MenuKeys.Personal + // [S92 2026-06-29] +Hợp đồng (Contracts + Ct_*) + Danh mục (Master + + // Suppliers + Projects + Departments + Catalog*) — anh chốt "chỉ Admin + // thấy". Pe_* (Duyệt NCC) KHÔNG nằm đây → module PE giữ nguyên cho user. + || p.MenuKey == MenuKeys.Contracts || p.MenuKey.StartsWith("Ct_") + || p.MenuKey == MenuKeys.Master || p.MenuKey == MenuKeys.Suppliers + || p.MenuKey == MenuKeys.Projects || p.MenuKey == MenuKeys.Departments + || p.MenuKey.StartsWith("Catalog")) && (p.CanRead || p.CanCreate || p.CanUpdate || p.CanDelete)) .ToListAsync(); @@ -2144,7 +2159,8 @@ public static class DbInitializer { await db.SaveChangesAsync(); logger.LogInformation( - "Revoked {Count} permission rows — tạm ẩn HRM/Văn phòng số/Cá nhân khỏi non-Admin (S58)", + "Revoked {Count} permission rows — ẩn HRM/Văn phòng số/Cá nhân/Hợp đồng/Danh mục " + + "khỏi non-Admin (chỉ Admin thấy — S58 + S92)", rows.Count); } } diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/AdminOnlyModulesRevokeTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/AdminOnlyModulesRevokeTests.cs new file mode 100644 index 0000000..dc73d31 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/AdminOnlyModulesRevokeTests.cs @@ -0,0 +1,191 @@ +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; + +// S92 test-before-merge (SECURITY — anh chốt 5 nhóm menu CHỈ Admin thấy trên eoffice). +// Guard hành vi RevokeTemporarilyHiddenModulesAsync sau khi S92 MỞ RỘNG predicate phủ thêm +// HỢP ĐỒNG (Contracts + Ct_*) + DANH MỤC (Master + Suppliers + Projects + Departments + Catalog*), +// cộng dồn nhóm cũ S58 NHÂN SỰ (Hrm*) + VĂN PHÒNG SỐ (Off*) + CÁ NHÂN (Personal). +// +// Cơ chế ẩn: menu hiện ⟺ Permission.CanRead=true (GetMyMenuTree lọc CanRead, KHÔNG admin-bypass). +// Revoke set 4 cờ CRUD=false cho MỌI role non-Admin trên các key match predicate → menu tự ẩn. +// Admin loại trừ khỏi revoke (giữ full → vẫn thấy hết). Pe_* (Duyệt NCC) KHÔNG match predicate +// → module PE giữ nguyên cho user (invariant bảo vệ tính năng đang dùng). +// +// S92 CŨNG TẮT 2 call re-grant trong SeedAsync: SeedAllRolesHrmProfileReadPermissionsAsync (S65) +// + SeedAllRolesOfficeModulePermissionsAsync (S69) → revoke là bước permission CUỐI, thắng mọi grant. +// 2 method grant đó vẫn `private static` (reversibility) → OfficeModulePermissionSeedTests + +// HrmProfilePermissionSeedTests vẫn invoke-reflection method-cô-lập (assertion KHÔNG đổi, vẫn pass). +// +// Reflection harness: RevokeTemporarilyHiddenModulesAsync là `private static` → +// BindingFlags.NonPublic|Static, signature (ApplicationDbContext, RoleManager, ILogger). +// Test ĐÚNG behavior code thật, KHÔNG re-implement predicate. +// +// 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). +public class AdminOnlyModulesRevokeTests +{ + // Đại diện 5 nhóm anh chốt admin-only — sau revoke PHẢI CanRead=false (non-Admin). + // Dùng const/factory MenuKeys (KHÔNG hardcode string) để bắt đồng-bộ nếu key đổi. + private static readonly string[] S92HiddenKeys = + { + // HỢP ĐỒNG (Contracts + Ct_*) + MenuKeys.Contracts, + MenuKeys.ContractTypeGroup("ThauPhu"), // "Ct_ThauPhu" — match StartsWith("Ct_") + // DANH MỤC (Master + 3 leaf + Catalog*) + MenuKeys.Master, + MenuKeys.Suppliers, + MenuKeys.Projects, + MenuKeys.Departments, + MenuKeys.Catalogs, // "Catalogs" — match StartsWith("Catalog") + MenuKeys.CatalogWorkItems, // "CatalogWorkItems" — match StartsWith("Catalog") + // Nhóm cũ S58 (regression — vẫn phải ẩn) + MenuKeys.Hrm, // "Hrm" — match StartsWith("Hrm") + MenuKeys.Off, // "Off" — match StartsWith("Off") + MenuKeys.Personal, // "Personal" — match == Personal + }; + + // Pe_* (Duyệt NCC) + root PurchaseEvaluations — KHÔNG bị revoke (giữ cho user thường). + private static readonly string[] PeKeptKeys = + { + MenuKeys.PurchaseEvaluations, // "PurchaseEvaluations" root — không match predicate + MenuKeys.PurchaseEvaluationGroup("DuyetNcc"), // "Pe_DuyetNcc" — không StartsWith Ct_/Catalog/Hrm/Off + }; + + private static readonly string[] AllMenuKeys = + S92HiddenKeys.Concat(PeKeptKeys).Distinct().ToArray(); + + // Reflection invoke private static (ApplicationDbContext, RoleManager, ILogger). + private static async Task InvokeRevokeAsync(IdentityFixture fix) + { + var db = fix.Services.GetRequiredService(); + var rm = fix.Services.GetRequiredService>(); + var mi = typeof(DbInitializer).GetMethod( + "RevokeTemporarilyHiddenModulesAsync", BindingFlags.NonPublic | BindingFlags.Static); + mi.Should().NotBeNull( + "DbInitializer.RevokeTemporarilyHiddenModulesAsync 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; + } + + // Ensure roles tồn tại (Admin + non-Admin). Trả (adminId, nonAdminId). + private static async Task<(Guid adminId, Guid nonAdminId)> SeedRolesAsync(IdentityFixture fix) + { + var rm = fix.Services.GetRequiredService>(); + var admin = new Role { Id = Guid.NewGuid(), Name = AppRoles.Admin }; + var drafter = new Role { Id = Guid.NewGuid(), Name = AppRoles.Drafter }; + await rm.CreateAsync(admin); + await rm.CreateAsync(drafter); + return (admin.Id, drafter.Id); + } + + // Seed MenuItem rows (FK target cho Permission.MenuKey). + private static async Task SeedMenuItemsAsync(TestApplicationDbContext db) + { + foreach (var key in AllMenuKeys) + db.MenuItems.Add(new MenuItem { Key = key, Label = key }); + await db.SaveChangesAsync(CancellationToken.None); + } + + // Seed 1 Permission row (CanRead mặc định + optional CanCreate — mô phỏng grant trước revoke). + private static void AddPerm( + TestApplicationDbContext db, Guid roleId, string menuKey, bool canRead, bool canCreate = false) + => db.Permissions.Add(new Permission + { + RoleId = roleId, + MenuKey = menuKey, + CanRead = canRead, + CanCreate = canCreate, + CanUpdate = false, + CanDelete = false, + }); + + 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 CanReadAsync(TestApplicationDbContext db, Guid roleId, string menuKey) + => (await GetPermAsync(db, roleId, menuKey))?.CanRead ?? false; + + // ⭐ LOAD-BEARING: non-Admin có read trên 5 nhóm → sau revoke TẤT CẢ 4 cờ CRUD=false (menu ẩn). + [Fact] + public async Task Revoke_NonAdmin_S92Modules_CanReadFalse() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var (_, nonAdminId) = await SeedRolesAsync(fix); + await SeedMenuItemsAsync(db); + + // Pre-grant CanRead=true cho non-Admin (Drafter) trên đại diện 5 nhóm → revoke có gì thu hồi. + foreach (var key in S92HiddenKeys) + AddPerm(db, nonAdminId, key, canRead: true); + await db.SaveChangesAsync(CancellationToken.None); + + await InvokeRevokeAsync(fix); + + foreach (var key in S92HiddenKeys) + { + var row = await GetPermAsync(db, nonAdminId, key); + row.Should().NotBeNull($"{key} vẫn có row (revoke chỉ hạ cờ, KHÔNG xóa — giữ vết flip lại)"); + row!.CanRead.Should().BeFalse($"{key} bị revoke CanRead — menu ẩn khỏi non-Admin (chỉ Admin thấy)"); + row.CanCreate.Should().BeFalse($"{key} revoke CanCreate"); + row.CanUpdate.Should().BeFalse($"{key} revoke CanUpdate"); + row.CanDelete.Should().BeFalse($"{key} revoke CanDelete"); + } + } + + // Admin loại trừ khỏi revoke → giữ nguyên CanRead=true (vẫn thấy 5 nhóm). + [Fact] + public async Task Revoke_Admin_Untouched_KeepsRead() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var (adminId, nonAdminId) = await SeedRolesAsync(fix); + await SeedMenuItemsAsync(db); + + // Cả Admin + non-Admin có read trên 5 nhóm — chỉ non-Admin bị revoke. + foreach (var key in S92HiddenKeys) + { + AddPerm(db, adminId, key, canRead: true); + AddPerm(db, nonAdminId, key, canRead: true); + } + await db.SaveChangesAsync(CancellationToken.None); + + await InvokeRevokeAsync(fix); + + foreach (var key in S92HiddenKeys) + (await CanReadAsync(db, adminId, key)).Should() + .BeTrue($"Admin KHÔNG bị revoke trên {key} — quản trị vẫn thấy đủ"); + } + + // ⭐ Invariant bảo vệ tính năng PE: Pe_* + root PurchaseEvaluations KHÔNG bị revoke (giữ cho user). + [Fact] + public async Task Revoke_DoesNotTouch_PeModule() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var (_, nonAdminId) = await SeedRolesAsync(fix); + await SeedMenuItemsAsync(db); + + // non-Admin có read (+create cho leaf Pe_*) — module Duyệt NCC đang dùng, KHÔNG được revoke. + foreach (var key in PeKeptKeys) + AddPerm(db, nonAdminId, key, canRead: true, canCreate: true); + await db.SaveChangesAsync(CancellationToken.None); + + await InvokeRevokeAsync(fix); + + foreach (var key in PeKeptKeys) + { + var row = await GetPermAsync(db, nonAdminId, key); + row!.CanRead.Should().BeTrue($"{key} KHÔNG bị revoke — module Duyệt NCC giữ cho user thường"); + row.CanCreate.Should().BeTrue($"{key} giữ CanCreate — user vẫn tạo phiếu Duyệt NCC"); + } + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/HrmProfilePermissionSeedTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/HrmProfilePermissionSeedTests.cs index 0f66cb7..4454fdd 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/HrmProfilePermissionSeedTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/HrmProfilePermissionSeedTests.cs @@ -9,6 +9,10 @@ using SolutionErp.Infrastructure.Tests.Common; namespace SolutionErp.Infrastructure.Tests.Application; +// [S92 2026-06-29] Call grant này (SeedAllRolesHrmProfileReadPermissionsAsync, S65) ĐÃ TẮT khỏi +// SeedAsync — anh chốt nhóm "Nhân sự" CHỈ Admin thấy. Test giữ guard method-CÔ-LẬP cho reversibility +// (method vẫn private static); hành vi prod THẬT = AdminOnlyModulesRevokeTests (revoke là bước cuối). +// Assertion dưới KHÔNG đổi — vẫn pass vì invoke trực tiếp method cô lập. // S66 test-after (UAT mode — "public Hồ sơ NS nhưng ẩn Dashboard/Config" shipped S65). // DbInitializer permission-seed chain (SeedAsync order, line 2033-2046): // 1. SeedAllRolesReviewReadPermissionsAsync (grant Master/Catalogs/Pe_* read) diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/OfficeModulePermissionSeedTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/OfficeModulePermissionSeedTests.cs index 2fd9aab..69bedea 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/OfficeModulePermissionSeedTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/OfficeModulePermissionSeedTests.cs @@ -9,6 +9,10 @@ using SolutionErp.Infrastructure.Tests.Common; namespace SolutionErp.Infrastructure.Tests.Application; +// [S92 2026-06-29] Call grant này (SeedAllRolesOfficeModulePermissionsAsync, S69) ĐÃ TẮT khỏi +// SeedAsync — anh chốt nhóm "Văn phòng số" CHỈ Admin thấy. Test giữ guard method-CÔ-LẬP cho +// reversibility (method vẫn private static); hành vi prod THẬT = AdminOnlyModulesRevokeTests +// (revoke là bước cuối). Assertion dưới KHÔNG đổi — vẫn pass vì invoke trực tiếp method cô lập. // S69 test-after (UAT mode — GOLIVE "public Văn phòng số cho all user eoffice" shipped S69). // Mirror HrmProfilePermissionSeedTests (S65/S66) — same reflection harness, but Office grant // mở READ + CREATE (self-service tạo phiếu) trên allow-list 16 key, KHÁC HRM chỉ READ 2 key.