[CLAUDE] Auth: PRO role giu quyen Master/NCC qua deploy (seeder chay-sau-revoke) + scope-isolation test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m33s

Van de (UAT anh Kiet FDC, S118): user tra.bui (Bui Le Thuy Tra, Procurement TP) dang nhap khong
thay menu quan ly NCC/Suppliers. Nguyen nhan: seeder S92 RevokeTemporarilyHiddenModulesAsync (chay
moi startup) set CanRead=false cho Master/Suppliers/Projects/Departments/Catalog* voi moi role
non-Admin ("chi Admin thay").

Fix (owner chot mo ngoai-le CHI role Procurement, ben qua deploy):
- Them SeedProcurementMasterAccessAsync chay NGAY SAU revoke de THANG (mirror pattern Hrm S65 /
  Office S69). Chi dung role Procurement -- S92 "chi Admin thay" GIU NGUYEN cho moi role khac.
- Grant 9 key: Suppliers (R+C+U -- quan ly + cong bo NCC, S113 publish can Suppliers.Update);
  Master/Projects/Departments/Catalogs + 4 Catalog leaf (Read-only). KHONG Delete.
- Dashboard/Reports CO Y KHONG cap (reviewer bat: Reports lo tong gia tri HD + top NCC/du an theo
  gia tri, nguoc S92; owner chot bo).
- Upgrade-only idempotent: nang co desired len true, khong ha co, khong dung role khac.

Test: +4 regression scope-isolation (ProcurementMasterAccessSeedTests) -- Procurement duoc nang
dung ma tran, role khac (DeptManager rieng) VAN bi revoke. 501 -> 505.

Reviewer PASS (scope cach-ly sach, no over-grant). Grant tam da ap prod (tra.bui verified 20 menu);
deploy nay = ban ben. Follow-up: gate ReportsController thieu policy (task rieng).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-14 15:29:44 +07:00
parent b56659b6b7
commit c2135bae2b
2 changed files with 338 additions and 0 deletions

View File

@ -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<Role>, 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<Role>, ILogger) ──
private static async Task InvokePrivateStaticAsync(IdentityFixture fix, string methodName)
{
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var rm = fix.Services.GetRequiredService<RoleManager<Role>>();
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<RoleManager<Role>>();
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<Permission?> 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<TestApplicationDbContext>();
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<TestApplicationDbContext>();
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<TestApplicationDbContext>();
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<TestApplicationDbContext>();
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");
}
}