[CLAUDE] Contract: K4a+K4b SP-002 sidebar 8 nhom x 6 leaf + noi ?group= end-to-end (0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m23s

K4a (gate PASS-WITH-FLAGS 4, sub-reviewer-gate-k4a.md): 49 key Khkk_G{2-8} bo-sinh
by-construction 3-phia (All/seed/grant) MenuKeys.cs + DbInitializer 49 row + labelBackfill
G1 nhan A1 + site-2 Concat + va 7/10/11/13/15 (nhan N2 co "pha do"; comment 1893 -> mo-neo
grep). All 64->113, Policies 256->452 (test T2 khoa runtime). Test +7 KhkkGroupMenuSeedTests
(T7 revoker-khong-dung-Khkk: probe vi-phan 112->0, gotcha #84).

K4b (gate PASS-WITH-FLAGS 4M/5m 0-chan, sub-reviewer-gate-k4b.md): KhkkListPage x2 derive
phase+group tu searchParams (leaf->leaf khong F5, navKey reset) + queryKey {group,phase,
search,page,deletedView} + KhkkCreatePage preset ?group= + staticMap G1 6-leaf group=1 +
regex Khkk_G([1-8])_* fe-user (fe-admin GHI RANG BUOC isAdminHidden) + FLAG-1 lead-fix:
GET /deleted +group mirror List (Controller+Query+handler SAU-IDOR) + FE noi 2-nhanh.

2 wave 1 commit CO KHAI: Layout.tsx dinh ca 2 wave khong tach hunk phi-tuong-tac; moi dong
deu qua gate rieng. STATUS canonical 5-row dong bo (71/97/452/113/620->621) + bundle :479.
No K4c: MAJOR-A test /deleted+group filter + MAJOR-C WfView doc group. Suite 621/0
(45D+576I) + filter 41/41 + npm x2 + dotnet 0E.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-01 10:10:11 +07:00
parent bc1e7c937d
commit 88e7ecf263
14 changed files with 880 additions and 63 deletions

View File

@ -0,0 +1,431 @@
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;
// [K4a — S166 2026-08-01] Test-after cho sidebar KHKK 8 NHÓM DUYỆT (danh mục SOL-CCM-SP-002
// Mig 70 chia 86 dòng thành 8 nhóm; mỗi nhóm 1 workflow `KHKK-N{n}` type-10 — K3).
//
// BE đã land: `MenuKeys.KhkkGroupNumbers/KhkkLeafActions/KhkkGroupMenuKeys` (49 key spread vào
// `All`) + vòng seed 49 menu-row trong `SeedMenuTreeAsync` + entry `labelBackfill["Khkk_G1"]`
// + site-2 grant `KhkkKeys().Concat(MenuKeys.KhkkGroupMenuKeys)`.
//
// KHÔNG nhân bản test đã có: `ContractCatalogTests.MenuKey_ContractCatalog_...` (T6 lane K1)
// đã assert `MenuKeys.All.Except(menuKeysInDb).BeEmpty()` sau `SeedMenuTreeAsync` ⇒ invariant
// "mọi key ∈ All có menu-row" (thiếu row = Permission FK nổ lúc boot) TỰ ĐỘNG phủ 49 key mới.
// File này đo phần T6 KHÔNG đo:
// T1 hình bộ sinh (49 key, group-trước-6-leaf, đúng chuỗi ký tự `Khkk_G{n}_{action}`)
// T2 `All` — chứa đủ 49 + KHÔNG key đôi + số tổng runtime + nhóm 1 CỐ Ý đứng ngoài
// T3 seed ×2 idempotent — 56 row họ Khkk (7 nhóm-1 + 49 mới), 0 nhân đôi, hình cây đúng
// T4 6 leaf nhóm 2..8 MIRROR nhãn/icon/Order của nhóm 1 (đo DB↔DB, không bảng hardcode)
// T5 labelBackfill đổi NHÃN mà KHÔNG thêm row + đối chứng key KHÔNG có entry thì KHÔNG tự lành
// T6 site-2 phát CanRead cho 49 key mới (read-only) + đối chứng Pe_* cùng lượt được CanCreate
//
// Harness: seeder là `private static` → REFLECTION (KHÔNG sửa prod đổi visibility).
// `SeedMenuTreeAsync(ApplicationDbContext, ILogger)`
// `SeedAllRolesReviewReadPermissionsAsync(ApplicationDbContext, RoleManager<Role>, ILogger)`
// Fixture `IdentityFixture` (cần RoleManager cho site-2) — KHÔNG chế fixture mới.
public class KhkkGroupMenuSeedTests
{
// ── 49 key kỳ vọng, VIẾT TAY (KHÔNG gọi lại `MenuKeys.KhkkGroup/KhkkLeaf` — gọi lại là
// tautology: bộ sinh sai chuỗi thì kỳ vọng cũng sai theo và test xanh vô nghĩa). ────────
private static readonly string[] Expected49 =
{
"Khkk_G2", "Khkk_G2_WfView", "Khkk_G2_List", "Khkk_G2_Create", "Khkk_G2_Pending", "Khkk_G2_Approved", "Khkk_G2_Deleted",
"Khkk_G3", "Khkk_G3_WfView", "Khkk_G3_List", "Khkk_G3_Create", "Khkk_G3_Pending", "Khkk_G3_Approved", "Khkk_G3_Deleted",
"Khkk_G4", "Khkk_G4_WfView", "Khkk_G4_List", "Khkk_G4_Create", "Khkk_G4_Pending", "Khkk_G4_Approved", "Khkk_G4_Deleted",
"Khkk_G5", "Khkk_G5_WfView", "Khkk_G5_List", "Khkk_G5_Create", "Khkk_G5_Pending", "Khkk_G5_Approved", "Khkk_G5_Deleted",
"Khkk_G6", "Khkk_G6_WfView", "Khkk_G6_List", "Khkk_G6_Create", "Khkk_G6_Pending", "Khkk_G6_Approved", "Khkk_G6_Deleted",
"Khkk_G7", "Khkk_G7_WfView", "Khkk_G7_List", "Khkk_G7_Create", "Khkk_G7_Pending", "Khkk_G7_Approved", "Khkk_G7_Deleted",
"Khkk_G8", "Khkk_G8_WfView", "Khkk_G8_List", "Khkk_G8_Create", "Khkk_G8_Pending", "Khkk_G8_Approved", "Khkk_G8_Deleted",
};
// 7 key nhóm 1 KHÔNG-infix, seed từ S159 — CẤM đổi (đổi key = mồ côi permission-row, bài S155).
private const string Group1GroupKey = "Khkk_G1";
private static readonly string[] Group1LeafKeys =
{ "Khkk_WfView", "Khkk_List", "Khkk_Create", "Khkk_Pending", "Khkk_Approved", "Khkk_Deleted" };
// Nhãn nhóm 1 sau K4a (entry `labelBackfill` mới) và nhãn skeleton S159 mà nó thay thế.
private const string Group1LabelAfterK4a = "1. Thiết bị, máy móc, vật tư phụ (A1)";
private const string Group1LabelSkeletonS159 = "1. Kế hoạch ký kết HĐ (NCC-TP)";
// ── Reflection helper (mirror `ContractCatalogTests.InvokePrivateSeedAsync`) ────────────
private static async Task InvokePrivateSeedAsync(
TestApplicationDbContext db, string methodName, RoleManager<Role>? rm = null)
{
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 args = rm is null
? new object[] { db, NullLogger.Instance }
: new object[] { db, rm, NullLogger.Instance };
await (Task)mi!.Invoke(null, args)!;
}
private static Task SeedMenuAsync(TestApplicationDbContext db)
=> InvokePrivateSeedAsync(db, "SeedMenuTreeAsync");
// Đếm họ Khkk bằng so-khớp Ordinal TRONG BỘ NHỚ (LIKE của SQLite case-insensitive ASCII —
// không muốn phép đếm phụ thuộc collation của provider).
private static async Task<List<string>> KhkkKeysInDbAsync(TestApplicationDbContext db)
{
var keys = await db.MenuItems.AsNoTracking().Select(m => m.Key).ToListAsync();
return keys.Where(k => k.StartsWith("Khkk", StringComparison.Ordinal)).OrderBy(k => k, StringComparer.Ordinal).ToList();
}
private static Task<MenuItem?> RowAsync(TestApplicationDbContext db, string key)
=> db.MenuItems.AsNoTracking().FirstOrDefaultAsync(m => m.Key == key);
// =====================================================================================
// T1 — HÌNH BỘ SINH: đúng 49 key, đúng THỨ TỰ group-trước-6-leaf, đúng chuỗi ký tự.
// `Equal` (thứ tự có nghĩa) chứ không `BeEquivalentTo`: nếu ai đổi `SelectMany`
// thành "7 group trước rồi 42 leaf sau" thì vòng seed menu vẫn chạy nhưng cây menu
// đổi hình ⇒ phải ĐỎ. Kèm phép kiểm liền-kề tường minh (G{n} đứng NGAY TRƯỚC
// G{n}_WfView) để thông điệp lỗi chỉ đúng thuộc tính bị vỡ.
// =====================================================================================
[Fact]
public void KhkkGroupMenuKeys_Is49Keys_GroupImmediatelyFollowedBySixLeaves()
{
MenuKeys.KhkkGroupMenuKeys.Should().HaveCount(49,
"7 nhóm (2..8) × (1 group + 6 leaf) = 49 — số này quyết định delta `Menu keys` + `Policies` ở docs/STATUS.md");
MenuKeys.KhkkGroupMenuKeys.Should().Equal(Expected49,
"bộ sinh phải ra ĐÚNG chuỗi `Khkk_G{{n}}` / `Khkk_G{{n}}_{{action}}` theo thứ tự group → 6 leaf; " +
"sai 1 ký tự là menu-row lệch key với `MenuKeys.All` ⇒ Permission FK nổ lúc boot");
// Liền-kề tường minh: mỗi group đứng ngay trước leaf `_WfView` của chính nó.
foreach (var n in MenuKeys.KhkkGroupNumbers)
{
var idx = Array.IndexOf(MenuKeys.KhkkGroupMenuKeys, $"Khkk_G{n}");
idx.Should().BeGreaterThanOrEqualTo(0, $"phải có key nhóm Khkk_G{n}");
MenuKeys.KhkkGroupMenuKeys[idx + 1].Should().Be($"Khkk_G{n}_WfView",
$"leaf đầu của nhóm {n} phải đứng NGAY SAU key nhóm — vòng seed menu dựa vào hình này");
}
MenuKeys.KhkkLeafActions.Should().Equal(
new[] { "WfView", "List", "Create", "Pending", "Approved", "Deleted" },
"6 action khớp Y 6 leaf nhóm 1 (owner: 'y chang Duyệt NCC') — thêm/bớt action là đổi 2 số canonical");
}
// =====================================================================================
// T2 — `MenuKeys.All`: chứa đủ 49 + KHÔNG key đôi + số tổng ĐO ĐƯỢC (Policies = |All|×4,
// DERIVED ⇒ số này là nguồn của 2 row canonical docs/STATUS.md).
// ⭐ Vế cuối = guard thật: nếu ai thêm số 1 vào `KhkkGroupNumbers` thì `Khkk_G1` vào
// `All` VÀ vòng seed thêm `tree.Add("Khkk_G1")` lần thứ hai (row cứng đã có ở
// initializer) ⇒ 2 entry cùng PK trong 1 lần SaveChanges = nổ lúc boot DB trống.
// =====================================================================================
[Fact]
public void MenuKeys_All_ContainsThe49_NoDuplicate_AndKeepsGroup1Outside()
{
MenuKeys.All.Should().Contain(Expected49,
"49 key phải nằm trong All — ngoài All thì Program.cs không sinh policy và Admin không được grant qua loop All");
MenuKeys.All.Should().OnlyHaveUniqueItems(
"key đôi trong All ⇒ SeedAdminPermissionsAsync chèn Permission trùng (RoleId, MenuKey) ⇒ nổ UNIQUE");
MenuKeys.All.Should().HaveCount(113,
"số ĐO runtime tại S166 sau K4a (+49). `Policies` = |All| × |Actions| = 113 × 4 = 452 — " +
"đổi số ở đây BẮT BUỘC đổi 2 row canonical `Menu keys` / `Policies` trong docs/STATUS.md");
MenuKeys.KhkkGroupNumbers.Should().Equal(new[] { 2, 3, 4, 5, 6, 7, 8 },
"nhóm 1 CỐ Ý ngoài bộ sinh — 7 key nhóm 1 không-infix đã có row cứng trong SeedMenuTreeAsync");
foreach (var g1 in Group1LeafKeys.Append(Group1GroupKey))
MenuKeys.All.Should().NotContain(g1,
$"{g1} (nhóm 1) giữ nguyên trạng thái NGOÀI All từ S159 — kéo vào All sẽ sinh menu-row trùng PK lúc seed");
}
// =====================================================================================
// T3 — SEED ×2 IDEMPOTENT: 56 row họ Khkk (7 nhóm-1 + 49 mới), lần 2 KHÔNG nhân đôi,
// và hình cây đúng (7 group mới treo dưới root `KeHoachKyKet`, Order = n, mỗi group
// đúng 6 con). Đếm tổng MenuItems trước/sau lần 2 để bắt cả trường hợp seeder nhân
// row Ở CHỖ KHÁC (không chỉ họ Khkk).
// =====================================================================================
[Fact]
public async Task SeedMenuTree_RunTwice_Has56KhkkRows_NoDuplication_AndCorrectTreeShape()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
await SeedMenuAsync(db);
var khkkAfter1 = await KhkkKeysInDbAsync(db);
var totalAfter1 = await db.MenuItems.AsNoTracking().CountAsync();
khkkAfter1.Should().HaveCount(56,
"7 row nhóm 1 (S159, không-infix) + 49 row nhóm 2..8 (K4a) — thiếu row nào là menu mất mục / Permission FK nổ");
khkkAfter1.Should().Contain(Expected49, "49 key mới phải có menu-row thật");
khkkAfter1.Should().Contain(Group1LeafKeys.Append(Group1GroupKey),
"7 row nhóm 1 KHÔNG được mất khi thêm 8-nhóm");
await SeedMenuAsync(db); // ⭐ lần 2
var khkkAfter2 = await KhkkKeysInDbAsync(db);
var totalAfter2 = await db.MenuItems.AsNoTracking().CountAsync();
khkkAfter2.Should().Equal(khkkAfter1, "idempotent — chạy lại KHÔNG thêm/bớt row họ Khkk");
totalAfter2.Should().Be(totalAfter1, "idempotent trên TOÀN bảng menu, không riêng họ Khkk");
// Hình cây: 7 group mới dưới root KeHoachKyKet, Order = số nhóm (xếp 1..8 cùng nhóm 1).
var g1 = await RowAsync(db, Group1GroupKey);
g1!.ParentKey.Should().Be(MenuKeys.KeHoachKyKet);
g1.Order.Should().Be(1, "nhóm 1 giữ Order 1 — 8 nhóm xếp 1..8 dưới root");
foreach (var n in MenuKeys.KhkkGroupNumbers)
{
var group = await RowAsync(db, $"Khkk_G{n}");
group.Should().NotBeNull($"Khkk_G{n} phải có row");
group!.ParentKey.Should().Be(MenuKeys.KeHoachKyKet, $"nhóm {n} treo dưới root Kế hoạch ký kết HĐ");
group.Order.Should().Be(n, $"Order nhóm = {n} (8 nhóm xếp 1..8 theo danh mục SP-002)");
group.Label.Should().NotBeNullOrWhiteSpace($"nhóm {n} phải có nhãn tiếng Việt (bảng 8 nhãn owner đã soát)");
var children = await db.MenuItems.AsNoTracking()
.Where(m => m.ParentKey == group.Key).Select(m => m.Key).ToListAsync();
children.Should().HaveCount(6, $"nhóm {n} đúng 6 leaf (WfView/List/Create/Pending/Approved/Deleted)");
}
// 8 nhãn nhóm phải KHÁC NHAU — copy-paste sót 1 dòng dictionary là 2 nhóm cùng tên trên sidebar.
var groupLabels = new List<string> { g1.Label };
foreach (var n in MenuKeys.KhkkGroupNumbers)
groupLabels.Add((await RowAsync(db, $"Khkk_G{n}"))!.Label);
groupLabels.Should().OnlyHaveUniqueItems("8 nhóm duyệt = 8 nhãn phân biệt trên sidebar");
}
// =====================================================================================
// T4 — 6 leaf nhóm 2..8 MIRROR Y nhóm 1 (nhãn + icon + Order). Đo DB↔DB: lấy row nhóm 1
// làm mốc thay vì bảng hardcode ⇒ đổi nhãn leaf mà quên đồng bộ 7 nhóm kia là ĐỎ.
// Chống vacuous: chốt trước rằng 6 nhãn nhóm 1 PHÂN BIỆT (nếu chúng trùng nhau hết
// thì phép so "khớp nhãn" đúng ở mọi thế giới).
// =====================================================================================
[Fact]
public async Task SeedMenuTree_NewGroupLeaves_MirrorGroup1_LabelIconOrder()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
await SeedMenuAsync(db);
var g1Leaves = new List<MenuItem>();
foreach (var key in Group1LeafKeys)
{
var row = await RowAsync(db, key);
row.Should().NotBeNull($"{key} (leaf nhóm 1, S159) phải còn — K4a không được đụng key cũ");
g1Leaves.Add(row!);
}
g1Leaves.Select(x => x.Label).Should().OnlyHaveUniqueItems(
"6 nhãn leaf nhóm 1 phải phân biệt — nếu trùng nhau, phép so mirror bên dưới đúng vô nghĩa");
for (var i = 0; i < MenuKeys.KhkkLeafActions.Length; i++)
{
var action = MenuKeys.KhkkLeafActions[i];
var mirror = g1Leaves[i];
mirror.Key.Should().Be($"Khkk_{action}",
"thứ tự KhkkLeafActions phải khớp thứ tự 6 leaf nhóm 1 — lệch thứ tự là so nhầm cặp");
foreach (var n in MenuKeys.KhkkGroupNumbers)
{
var leaf = await RowAsync(db, $"Khkk_G{n}_{action}");
leaf.Should().NotBeNull($"Khkk_G{n}_{action} phải có row");
leaf!.ParentKey.Should().Be($"Khkk_G{n}", "leaf treo dưới đúng nhóm của nó");
leaf.Label.Should().Be(mirror.Label, $"nhãn leaf {action} nhóm {n} phải Y nhóm 1 ({mirror.Label})");
leaf.Icon.Should().Be(mirror.Icon, $"icon leaf {action} nhóm {n} phải Y nhóm 1");
leaf.Order.Should().Be(mirror.Order, $"Order leaf {action} nhóm {n} phải Y nhóm 1 (1..6)");
}
}
}
// =====================================================================================
// T5 — labelBackfill: đổi NHÃN nhóm 1 mà KHÔNG thêm row (gotcha #11 — sửa chữ trong
// `tree.Add` là vô tác dụng trên DB đang chạy vì upsert chỉ re-set Order).
// ⭐ MA TRẬN cùng-ruột-khác-1-biến: bôi hỏng nhãn của CẢ HAI key trong cùng 1 lần,
// rồi seed lại — `Khkk_G1` (CÓ entry backfill) tự lành, `Khkk_G2` (KHÔNG có entry)
// GIỮ NGUYÊN chữ hỏng. Khác biệt duy nhất = sự có mặt trong `labelBackfill` ⇒ chứng
// chính cơ chế backfill làm việc, không phải upsert chính "tiện tay" ghi đè Label.
// =====================================================================================
[Fact]
public async Task SeedMenuTree_LabelBackfill_HealsGroup1Label_WithoutAddingRows_ControlKeyStaysDirty()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
await SeedMenuAsync(db);
var countBefore = await db.MenuItems.AsNoTracking().CountAsync();
(await RowAsync(db, Group1GroupKey))!.Label.Should().Be(Group1LabelAfterK4a,
"DB trống: nhãn A1 đến thẳng từ tree initializer");
// Bôi hỏng nhãn 2 key: G1 = nhãn skeleton S159 (ca THẬT trên prod), G2 = chuỗi lạ (đối chứng).
const string dirtyG2 = "ZZZ nhãn hỏng đối chứng";
var g1 = await db.MenuItems.FirstAsync(m => m.Key == Group1GroupKey);
var g2 = await db.MenuItems.FirstAsync(m => m.Key == "Khkk_G2");
g1.Label = Group1LabelSkeletonS159;
g2.Label = dirtyG2;
await db.SaveChangesAsync(CancellationToken.None);
// Sanity: mutation ĐÃ land (nếu không, "sau seed nhãn đúng" xanh vì chưa từng hỏng).
(await RowAsync(db, Group1GroupKey))!.Label.Should().Be(Group1LabelSkeletonS159);
(await RowAsync(db, "Khkk_G2"))!.Label.Should().Be(dirtyG2);
await SeedMenuAsync(db); // ⭐ lần 2 — labelBackfill chạy
(await RowAsync(db, Group1GroupKey))!.Label.Should().Be(Group1LabelAfterK4a,
"entry labelBackfill[\"Khkk_G1\"] phải kéo nhãn skeleton S159 về nhãn nhóm A1 trên DB đang chạy");
(await RowAsync(db, "Khkk_G2"))!.Label.Should().Be(dirtyG2,
"ĐỐI CHỨNG: Khkk_G2 KHÔNG có entry backfill ⇒ upsert chính chỉ re-set Order, KHÔNG ghi đè Label " +
"(gotcha #11). Đổi nhãn G2..G8 sau khi đã seed thì BẮT BUỘC thêm entry backfill.");
var countAfter = await db.MenuItems.AsNoTracking().CountAsync();
countAfter.Should().Be(countBefore, "đổi nhãn KHÔNG được sinh row mới (đổi key mới sinh row + mồ côi permission)");
}
// =====================================================================================
// T6 — SITE-2 (`SeedAllRolesReviewReadPermissionsAsync`): role thường có CanRead trên 49
// key mới (điểm điều hướng của sidebar). Cờ ghi = false vì nhánh non-Pe là read-only.
// ⭐ Chống vacuous bằng ĐỐI CHỨNG CÙNG LƯỢT: cùng role đó, key `Pe_DuyetNcc_List`
// phải có CanCreate=true (nhánh isPe) ⇒ "C/U/D=false trên Khkk" không phải vì seeder
// không chạy, mà vì seeder PHÂN BIỆT 2 họ key.
// Chạy SeedMenuTree trước = smoke FK: mọi key trong grant-set phải có menu-row thật.
// =====================================================================================
[Fact]
public async Task SeedAllRolesReviewRead_GrantsReadOnNew49KhkkKeys_ReadOnly_WithPeContrast()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var rm = fix.Services.GetRequiredService<RoleManager<Role>>();
var admin = new Role { Id = Guid.NewGuid(), Name = AppRoles.Admin };
var normal = new Role { Id = Guid.NewGuid(), Name = AppRoles.Drafter };
(await rm.CreateAsync(admin)).Succeeded.Should().BeTrue();
(await rm.CreateAsync(normal)).Succeeded.Should().BeTrue();
await SeedMenuAsync(db);
await InvokePrivateSeedAsync(db, "SeedAllRolesReviewReadPermissionsAsync", rm);
var rows = (await db.Permissions.AsNoTracking()
.Where(p => p.RoleId == normal.Id).ToListAsync())
.ToDictionary(p => p.MenuKey);
// Mục được đề bài gọi tên (leaf MỚI) — nêu riêng để thông điệp lỗi nói đúng chỗ.
rows.Should().ContainKey("Khkk_G2_List", "leaf mới phải được site-2 phát quyền cho role thường");
rows["Khkk_G2_List"].CanRead.Should().BeTrue(
"menu hiện ⟺ CanRead=true (GetMyMenuTree lọc CanRead, KHÔNG admin-bypass cho role thường)");
foreach (var key in Expected49)
{
rows.Should().ContainKey(key, $"{key} phải có Permission row cho role thường (site-2 grant)");
var row = rows[key];
row.CanRead.Should().BeTrue($"{key}: role thường phải đọc được mục sidebar");
row.CanCreate.Should().BeFalse($"{key}: nhánh non-Pe là read-only (cờ GHI của KHKK do site-3 phát trên root)");
row.CanUpdate.Should().BeFalse($"{key}: read-only");
row.CanDelete.Should().BeFalse($"{key}: read-only");
}
// 7 key nhóm 1 vẫn được phát (K4a `.Concat` mở rộng, KHÔNG thay thế).
foreach (var key in Group1LeafKeys.Append(Group1GroupKey))
rows[key].CanRead.Should().BeTrue($"{key} (nhóm 1) phải giữ CanRead sau khi K4a nối thêm 49 key");
// ⭐ ĐỐI CHỨNG cùng lượt seed: họ Pe_* đi nhánh isPe ⇒ CanCreate=true.
var peList = MenuKeys.PurchaseEvaluationList("DuyetNcc");
rows[peList].CanCreate.Should().BeTrue(
"chứng seeder CÓ chạy và CÓ phân biệt 2 họ key — nếu không, mọi assert 'false' bên trên đúng vô nghĩa");
}
// =====================================================================================
// T7 — [finding D, gate K4a @S166] ⭐ BẤT-BIẾN AN NINH-DỮ-LIỆU: `RevokeTemporarilyHidden-
// ModulesAsync` KHÔNG được đụng họ `Khkk_*`.
// Đây là ca ĐÃ VỠ THẬT trên prod, không phải giả định: 2026-07-29 grant 38 key bằng
// SQL tay bị CHÍNH revoker này lật ở app-restart kế (Run #423 — 447/494 row rơi,
// 11/13 role về 0; gotcha #84). Thêm 1 prefix vào bộ lọc revoke = 49 row Khkk vừa cấp
// biến mất Ở LẦN RESTART SAU, im lặng, không test nào đỏ ⇒ Fact này bịt đúng lỗ đó.
// Khuôn 2-vế mirror `AdminOnlyModulesRevokeTests` (StillHidden ∧ Reopened).
//
// 🔴 BẪY VACUOUS ĐÃ CHỐNG (đọc từ mã `:2546-2551`): thiếu role Admin thì revoker
// RETURN SỚM (no-op) ⇒ mọi assert "Khkk còn nguyên" xanh vì revoker CHƯA TỪNG CHẠY.
// Nên test này (a) seed Admin, (b) có CONTROL DƯƠNG: cùng lượt đó, key `Off_*` và
// `Personal` của CHÍNH 2 role kia PHẢI bị hạ hết cờ. Control dương đỏ ⇔ phép đo hỏng.
// Đo trên 2 role non-Admin (khẳng định là "mọi role", 1 role không đủ nói) + kiểm
// Admin được loại trừ.
// =====================================================================================
[Fact]
public async Task Revoke_DoesNotTouch_KhkkGroups()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var rm = fix.Services.GetRequiredService<RoleManager<Role>>();
// Admin BẮT BUỘC tồn tại, nếu không revoker return sớm và test thành vô nghĩa.
var admin = new Role { Id = Guid.NewGuid(), Name = AppRoles.Admin };
var role1 = new Role { Id = Guid.NewGuid(), Name = AppRoles.Drafter };
var role2 = new Role { Id = Guid.NewGuid(), Name = AppRoles.DeptManager };
foreach (var r in new[] { admin, role1, role2 })
(await rm.CreateAsync(r)).Succeeded.Should().BeTrue();
await SeedMenuAsync(db);
await InvokePrivateSeedAsync(db, "SeedAllRolesReviewReadPermissionsAsync", rm);
// CONTROL DƯƠNG: 2 key NẰM TRONG tập revoke (Off* + Personal) — site-2 không phát
// 2 key này nên cấp tay. Có menu-row thật từ SeedMenuTreeAsync ⇒ FK ok.
var revokedSetKeys = new[] { MenuKeys.OffDanhBa, MenuKeys.Personal };
foreach (var roleId in new[] { admin.Id, role1.Id, role2.Id })
foreach (var key in revokedSetKeys)
db.Permissions.Add(new Permission
{
RoleId = roleId, MenuKey = key,
CanRead = true, CanCreate = false, CanUpdate = false, CanDelete = false,
});
// Mô phỏng admin tick thêm cờ GHI cho 1 mục Khkk trên ma trận Phân quyền — revoker
// (nếu bắt nhầm `Khkk_*`) sẽ xoá luôn cờ này, không chỉ CanRead.
const string tickedKey = "Khkk_G5_Create";
var ticked = await db.Permissions.FirstAsync(p => p.RoleId == role1.Id && p.MenuKey == tickedKey);
ticked.CanCreate = true;
await db.SaveChangesAsync(CancellationToken.None);
await InvokePrivateSeedAsync(db, "RevokeTemporarilyHiddenModulesAsync", rm);
var after = (await db.Permissions.AsNoTracking().ToListAsync())
.ToDictionary(p => (p.RoleId, p.MenuKey));
// ── VẾ 1 (CONTROL DƯƠNG): revoker THẬT SỰ đã chạy và có hiệu lực trên non-Admin ──
foreach (var roleId in new[] { role1.Id, role2.Id })
foreach (var key in revokedSetKeys)
{
var row = after[(roleId, key)];
row.CanRead.Should().BeFalse(
$"{key} thuộc tập ẩn (Hrm*/Off*/Personal) ⇒ PHẢI bị hạ cờ. Vế này ĐỎ = revoker không chạy " +
"(vd Admin chưa seed) ⇒ mọi khẳng định 'Khkk còn nguyên' bên dưới vô giá trị");
row.CanCreate.Should().BeFalse();
row.CanUpdate.Should().BeFalse();
row.CanDelete.Should().BeFalse();
}
// Admin được loại trừ khỏi revoke (chứng bộ lọc `RoleId != adminRoleId` còn sống).
after[(admin.Id, MenuKeys.OffDanhBa)].CanRead.Should().BeTrue(
"Admin KHÔNG bị revoke — nếu vế này đỏ thì revoker đang quét quá tay, kết luận về Khkk cũng đáng ngờ");
// ── VẾ 2 (BẤT-BIẾN CẦN BẢO VỆ): 56 key họ Khkk GIỮ NGUYÊN quyền trên MỌI role ──
var allKhkkKeys = Expected49.Concat(Group1LeafKeys).Append(Group1GroupKey).ToArray();
allKhkkKeys.Should().HaveCount(56);
var survivors = 0;
foreach (var roleId in new[] { role1.Id, role2.Id })
foreach (var key in allKhkkKeys)
{
after.Should().ContainKey((roleId, key),
$"{key}: revoke chỉ HẠ CỜ, KHÔNG xoá row — mất row là hỏng nặng hơn cả gotcha #84");
after[(roleId, key)].CanRead.Should().BeTrue(
$"{key} KHÔNG thuộc Hrm*/Off*/Personal ⇒ revoker phải THA. Đỏ ở đây = đúng ca prod Run #423 " +
"(grant xong bị lật ở restart kế) tái diễn cho sidebar KHKK 8 nhóm");
survivors++;
}
// Nói bằng ĐƠN VỊ của sự cố thật (đếm row sống sót) — 56 key × 2 role non-Admin.
survivors.Should().Be(112, "toàn bộ row Khkk của 2 role non-Admin phải sống qua revoke");
after[(role1.Id, tickedKey)].CanCreate.Should().BeTrue(
$"{tickedKey}: cờ GHI admin tự tick trên ma trận Phân quyền cũng không được revoker chạm");
}
}