[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
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:
@ -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<Role> 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
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user