[CLAUDE] Infra: go nhanh S92 khoi revoker (owner supersede: hien thi het) + skeleton menu GD2/GD4 + ComingSoonPage x2
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m56s

- RevokeTemporarilyHiddenModulesAsync: GO Contracts+Ct_*/Master-set khoi predicate (bai tra gia: grant SQL tay bi revoker lat o restart #423 - 447/494 row roi; regrant=447 da chay truoc commit nay). Hrm/Off/Personal GIU (y owner: vung dang-lam chi admin)
- reviewKeys += ContractMenuKeys + MasterMenuKeys + KhkkKeys + HdcKeys (CanRead-only all-role, fresh-env hoi tu)
- Seeder tree.Add: Khkk_G1 + 4 leaves (khuon Duyet NCC) + Hdc_x7 (tai dung nhan 01.-07.) - prod da INSERT truc tiep (perm=156)
- ComingSoonPage mirror x2 (md5-identical) + route /coming-soon x2 + staticMap 11 leaf x2 (gotcha #50)
- Test: AdminOnlyModulesRevokeTests tach StillHidden/Reopened + REGRESSION-GUARD S159 (re-add nhanh S92 => do); ProcurementMasterAccessSeedTests doc lai isolation bang CO CAO (spec-change S92->S159, update test + code chung commit)
- 562 test PASS - nghiem thu cuoi = restart THAT khi deploy run nay (khuon S91)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-29 12:34:15 +07:00
parent b5a7dd8a92
commit 2a7269581c
9 changed files with 234 additions and 27 deletions

View File

@ -5,6 +5,7 @@ import { ProtectedRoute } from '@/components/ProtectedRoute'
import { Layout } from '@/components/Layout'
import { LoginPage } from '@/pages/LoginPage'
import { DashboardPage } from '@/pages/DashboardPage'
import { ComingSoonPage } from '@/pages/ComingSoonPage'
import { SuppliersPage } from '@/pages/master/SuppliersPage'
import { ProjectsPage } from '@/pages/master/ProjectsPage'
import { DepartmentsPage } from '@/pages/master/DepartmentsPage'
@ -55,6 +56,8 @@ function App() {
}
>
<Route path="/dashboard" element={<DashboardPage />} />
{/* [S159] trang dich cho menu skeleton GD2/GD4 */}
<Route path="/coming-soon" element={<ComingSoonPage />} />
<Route path="/master/suppliers" element={<SuppliersPage />} />
<Route path="/master/projects" element={<ProjectsPage />} />
<Route path="/master/departments" element={<DepartmentsPage />} />

View File

@ -29,6 +29,19 @@ const TYPE_CODE_TO_INT: Record<string, number> = {
// Ct_<Type>_<Action> sub-menu entries.
function resolvePath(key: string): string | null {
const staticMap: Record<string, string> = {
// [S159-đợt3] skeleton GĐ2/GĐ4 — leaf trỏ trang "Sắp triển khai" (query giữ
// active-state riêng từng mục; gotcha #50: thiếu route = sidebar drop SILENT).
Khkk_List: '/coming-soon?stage=2&m=list',
Khkk_Create: '/coming-soon?stage=2&m=create',
Khkk_Pending: '/coming-soon?stage=2&m=pending',
Khkk_Approved: '/coming-soon?stage=2&m=approved',
Hdc_ThauPhu: '/coming-soon?stage=4&t=01',
Hdc_GiaoKhoan: '/coming-soon?stage=4&t=02',
Hdc_NhaCungCap: '/coming-soon?stage=4&t=03',
Hdc_DichVu: '/coming-soon?stage=4&t=04',
Hdc_MuaBan: '/coming-soon?stage=4&t=05',
Hdc_NguyenTacNcc: '/coming-soon?stage=4&t=06',
Hdc_NguyenTacDv: '/coming-soon?stage=4&t=07',
Dashboard: '/dashboard',
Suppliers: '/master/suppliers',
Projects: '/master/projects',

View File

@ -0,0 +1,54 @@
// ComingSoonPage — trang đích cho các mục menu skeleton GĐ2/GĐ4 (module chưa build).
// [S159 2026-07-29] Owner: menu hiển thị đủ 4 giai đoạn + phân list như Duyệt NCC
// NGAY TỪ BÂY GIỜ để mọi người góp ý — leaf chưa có module trỏ về đây thay vì
// link chết. MIRROR ×2 app BYTE-IDENTICAL (nút quay lại dùng navigate(-1) để
// không phụ thuộc route riêng từng app).
import { useNavigate, useSearchParams } from 'react-router-dom'
import { ArrowLeft, Hourglass } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
const STAGE_INFO: Record<string, { name: string; steps: string; desc: string }> = {
'2': {
name: 'Kế hoạch ký kết HĐ',
steps: 'Bước 7 → 12',
desc: 'Phiếu nối tiếp từ phiếu Duyệt NCC đã duyệt — duyệt mẫu/shopdrawing, so sánh giá và chốt giá trị ký kết hợp đồng.',
},
'4': {
name: 'Hợp đồng cứng',
steps: 'Bước 19 → 21',
desc: 'Lưu bản hợp đồng đã ký cứng (tải lên bản scan sau khi ký, đóng dấu và phát hành).',
},
}
export function ComingSoonPage() {
const navigate = useNavigate()
const [params] = useSearchParams()
const stage = params.get('stage') ?? '2'
const info = STAGE_INFO[stage] ?? STAGE_INFO['2']
return (
<div className="p-6">
<div className="mx-auto max-w-3xl rounded-lg border border-slate-200 bg-white shadow-sm">
<PePipelineStrip current={Number(stage)} />
<div className="flex flex-col items-center px-6 py-10 text-center">
<span className="icon-chip mb-3">
<Hourglass className="h-4 w-4" />
</span>
<h2 className="text-lg font-semibold text-slate-900">{info.name}</h2>
<div className="mt-1 text-xs font-medium text-slate-500">
Giai đoạn {stage} · {info.steps}
</div>
<p className="mt-3 max-w-md text-sm leading-relaxed text-slate-600">{info.desc}</p>
<p className="mt-2 rounded-md bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700">
Phân hệ đang đưc triển khai cấu trúc menu hiển thị trước đ mọi người góp ý.
</p>
<Button variant="outline" size="sm" className="mt-5" onClick={() => navigate(-1)}>
<ArrowLeft className="h-3.5 w-3.5" />
Quay lại
</Button>
</div>
</div>
</div>
)
}

View File

@ -5,6 +5,7 @@ import { ProtectedRoute } from '@/components/ProtectedRoute'
import { Layout } from '@/components/Layout'
import { LoginPage } from '@/pages/LoginPage'
import { UserDashboardPage } from '@/pages/UserDashboardPage'
import { ComingSoonPage } from '@/pages/ComingSoonPage'
import { InboxPage } from '@/pages/InboxPage'
import { SuppliersPage } from '@/pages/master/SuppliersPage'
import { ProjectsPage } from '@/pages/master/ProjectsPage'
@ -47,6 +48,8 @@ function App() {
}
>
<Route path="/dashboard" element={<UserDashboardPage />} />
{/* [S159] trang dich cho menu skeleton GD2/GD4 */}
<Route path="/coming-soon" element={<ComingSoonPage />} />
<Route path="/inbox" element={<InboxPage />} />
<Route path="/master/suppliers" element={<SuppliersPage />} />
<Route path="/master/projects" element={<ProjectsPage />} />

View File

@ -54,6 +54,19 @@ const INT_TO_PE_CODE: Record<number, string> = Object.fromEntries(
// /my-contracts (user's own drafts), Duyệt to /inbox (pending THEIR approval).
function resolvePath(key: string): string | null {
const staticMap: Record<string, string> = {
// [S159-đợt3] skeleton GĐ2/GĐ4 — leaf trỏ trang "Sắp triển khai" (query giữ
// active-state riêng từng mục; gotcha #50: thiếu route = sidebar drop SILENT).
Khkk_List: '/coming-soon?stage=2&m=list',
Khkk_Create: '/coming-soon?stage=2&m=create',
Khkk_Pending: '/coming-soon?stage=2&m=pending',
Khkk_Approved: '/coming-soon?stage=2&m=approved',
Hdc_ThauPhu: '/coming-soon?stage=4&t=01',
Hdc_GiaoKhoan: '/coming-soon?stage=4&t=02',
Hdc_NhaCungCap: '/coming-soon?stage=4&t=03',
Hdc_DichVu: '/coming-soon?stage=4&t=04',
Hdc_MuaBan: '/coming-soon?stage=4&t=05',
Hdc_NguyenTacNcc: '/coming-soon?stage=4&t=06',
Hdc_NguyenTacDv: '/coming-soon?stage=4&t=07',
Dashboard: '/dashboard',
Contracts: '/my-contracts',
PurchaseEvaluations: '/purchase-evaluations',

View File

@ -0,0 +1,54 @@
// ComingSoonPage — trang đích cho các mục menu skeleton GĐ2/GĐ4 (module chưa build).
// [S159 2026-07-29] Owner: menu hiển thị đủ 4 giai đoạn + phân list như Duyệt NCC
// NGAY TỪ BÂY GIỜ để mọi người góp ý — leaf chưa có module trỏ về đây thay vì
// link chết. MIRROR ×2 app BYTE-IDENTICAL (nút quay lại dùng navigate(-1) để
// không phụ thuộc route riêng từng app).
import { useNavigate, useSearchParams } from 'react-router-dom'
import { ArrowLeft, Hourglass } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
const STAGE_INFO: Record<string, { name: string; steps: string; desc: string }> = {
'2': {
name: 'Kế hoạch ký kết HĐ',
steps: 'Bước 7 → 12',
desc: 'Phiếu nối tiếp từ phiếu Duyệt NCC đã duyệt — duyệt mẫu/shopdrawing, so sánh giá và chốt giá trị ký kết hợp đồng.',
},
'4': {
name: 'Hợp đồng cứng',
steps: 'Bước 19 → 21',
desc: 'Lưu bản hợp đồng đã ký cứng (tải lên bản scan sau khi ký, đóng dấu và phát hành).',
},
}
export function ComingSoonPage() {
const navigate = useNavigate()
const [params] = useSearchParams()
const stage = params.get('stage') ?? '2'
const info = STAGE_INFO[stage] ?? STAGE_INFO['2']
return (
<div className="p-6">
<div className="mx-auto max-w-3xl rounded-lg border border-slate-200 bg-white shadow-sm">
<PePipelineStrip current={Number(stage)} />
<div className="flex flex-col items-center px-6 py-10 text-center">
<span className="icon-chip mb-3">
<Hourglass className="h-4 w-4" />
</span>
<h2 className="text-lg font-semibold text-slate-900">{info.name}</h2>
<div className="mt-1 text-xs font-medium text-slate-500">
Giai đoạn {stage} · {info.steps}
</div>
<p className="mt-3 max-w-md text-sm leading-relaxed text-slate-600">{info.desc}</p>
<p className="mt-2 rounded-md bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700">
Phân hệ đang đưc triển khai cấu trúc menu hiển thị trước đ mọi người góp ý.
</p>
<Button variant="outline" size="sm" className="mt-5" onClick={() => navigate(-1)}>
<ArrowLeft className="h-3.5 w-3.5" />
Quay lại
</Button>
</div>
</div>
</div>
)
}

View File

@ -1773,6 +1773,14 @@ public static class DbInitializer
// FE staticMap trỏ /dashboard. Prod đã INSERT trực tiếp cùng ngày (idempotent ở đây).
(MenuKeys.KeHoachKyKet, "Kế hoạch ký kết HĐ", null, 26, "FileSignature"),
(MenuKeys.HopDongCung, "Hợp đồng cứng", null, 32, "Stamp"),
// [S159-đợt3] skeleton menu GĐ2/GĐ4 (owner vẽ: "viết hoa + phân list như
// Duyệt NCC" / "thêm Sub đánh số như Duyệt Hợp đồng"). Root CÓ CON ⇒ render
// MenuGroup uppercase tự động. Leaves → /coming-soon (staticMap ×2 app).
("Khkk_G1", "1. Kế hoạch ký kết HĐ (NCC-TP)", MenuKeys.KeHoachKyKet, 1, "FileCheck"),
("Khkk_List", "Danh sách", "Khkk_G1", 1, "List"),
("Khkk_Create", "Thao tác", "Khkk_G1", 2, "Plus"),
("Khkk_Pending", "Đang duyệt", "Khkk_G1", 3, "CheckCircle2"),
("Khkk_Approved", "Đã duyệt", "Khkk_G1", 4, "CheckCheck"),
(MenuKeys.Forms, "Biểu mẫu", null, 40, "FileSpreadsheet"),
(MenuKeys.Reports, "Báo cáo", null, 50, "BarChart3"),
(MenuKeys.System, "Hệ thống", null, 90, "Settings"),
@ -1847,9 +1855,13 @@ public static class DbInitializer
// Per-type sub-menu under Contracts: 1 group + 3 leaves each
// (Danh sách / Thao tác / Duyệt).
var order = 31;
var hdcOrder = 1;
foreach (var code in MenuKeys.ContractTypeCodes)
{
var label = typeLabels.GetValueOrDefault(code, code);
// [S159-đợt3] GĐ4 skeleton — 1 leaf/loại HĐ dưới root Hợp đồng cứng, tái dùng
// đúng nhãn đã đánh số 01.-07. (leaf → /coming-soon?stage=4).
tree.Add(("Hdc_" + code, label, MenuKeys.HopDongCung, hdcOrder++, "FileText"));
tree.Add((MenuKeys.ContractTypeGroup(code), label, MenuKeys.Contracts, order++, "FileText"));
tree.Add((MenuKeys.ContractTypeList(code), "Danh sách", MenuKeys.ContractTypeGroup(code), order++, "List"));
tree.Add((MenuKeys.ContractTypeCreate(code), "Thao tác", MenuKeys.ContractTypeGroup(code), order++, "Plus"));
@ -2115,6 +2127,31 @@ public static class DbInitializer
// → "PeWf_*"/"PeWorkflows" KHÔNG match (loại admin Designer).
static bool IsPeKey(string key) => key.StartsWith("Pe_");
// [S159] các bộ key grant CanRead-only mọi role (xem chú thích tại reviewKeys).
static IEnumerable<string> ContractMenuKeys()
{
yield return MenuKeys.Contracts;
foreach (var c in MenuKeys.ContractTypeCodes)
{
yield return MenuKeys.ContractTypeGroup(c);
yield return MenuKeys.ContractTypeList(c);
yield return MenuKeys.ContractTypeCreate(c);
yield return MenuKeys.ContractTypePending(c);
}
}
static IEnumerable<string> MasterMenuKeys() => new[]
{
MenuKeys.Master, MenuKeys.Catalogs, MenuKeys.CatalogUnits, MenuKeys.CatalogMaterials,
MenuKeys.CatalogServices, MenuKeys.CatalogWorkItems, MenuKeys.Departments,
MenuKeys.Projects, MenuKeys.Suppliers,
};
static IEnumerable<string> KhkkKeys() => new[]
{
"Khkk_G1", "Khkk_List", "Khkk_Create", "Khkk_Pending", "Khkk_Approved",
};
static IEnumerable<string> HdcKeys()
=> MenuKeys.ContractTypeCodes.Select(c => "Hdc_" + c);
// MenuKeys.All chứa root PurchaseEvaluations nhưng KHÔNG chứa Pe_* leaf
// (sinh động qua factory). Build leaf giống SeedPurchaseEvaluationPermissionDefaultsAsync
// để upgrade đúng row Pe_* thật trong DB (1 root + 5 leaf × 2 type).
@ -2137,6 +2174,14 @@ public static class DbInitializer
// [S159] 2 root placeholder GĐ2/GĐ4 — CanRead-only mọi role (IsPeKey=false
// ⇒ nhánh read-only). Prod đã INSERT trực tiếp; đây giữ fresh-DB hội tụ.
.Concat(new[] { MenuKeys.KeHoachKyKet, MenuKeys.HopDongCung })
// [S159] skeleton GĐ2 (Khkk_*) + GĐ4 (Hdc_*) — leaves trỏ /coming-soon.
.Concat(KhkkKeys()).Concat(HdcKeys())
// [S159] owner "hiển thị hết": HĐ (Contracts+Ct_*) + DANH MỤC (Master-set)
// CanRead-only mọi role — cặp với việc GỠ nhánh [S92] khỏi revoker ở trên.
// Lưu ý: nhánh grant key-thường là skip-existing ⇒ trên prod row false
// KHÔNG được nâng ở đây — re-grant prod đã chạy SQL trực tiếp cùng ngày;
// đây là đường hội tụ cho fresh-DB/env khác.
.Concat(ContractMenuKeys()).Concat(MasterMenuKeys())
.Distinct()
.ToArray();
var roles = await roleManager.Roles.ToListAsync();
@ -2214,14 +2259,16 @@ 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
// [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.MenuKey == MenuKeys.Personal)
// [S159 2026-07-29] 🔴 GỠ nhánh [S92] (Contracts+Ct_* / Master+Suppliers+
// Projects+Departments+Catalog*) — owner SUPERSEDE chính quyết định S92
// "chỉ Admin thấy": nay "đang phát triển → cho hiển thị hết để mọi người
// góp ý; Duyệt NCC để như cũ". Bài trả giá cùng ngày: grant 38 key bằng
// SQL tay bị CHÍNH revoker này lật lại ở app-restart kế (Run #423 —
// 447/494 row rơi, 11/13 role về 0; cicd-verify-1a47a61.md mục 9).
// Đúng chiều-ngược gotcha #75/#76: seeder ungated thắng data-change tay
// ⇒ đổi trạng-thái bền = đổi CODE, nghiệm thu = restart THẬT.
// Hrm*/Off*/Personal GIỮ trong revoker (vùng đang-làm, ý ❸ owner).
&& (p.CanRead || p.CanCreate || p.CanUpdate || p.CanDelete))
.ToListAsync();

View File

@ -32,26 +32,37 @@ namespace SolutionErp.Infrastructure.Tests.Application;
// (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 =
// [S159 2026-07-29] Owner SUPERSEDE S92: "đang phát triển → cho hiển thị hết để mọi
// người góp ý; Duyệt NCC để như cũ" ⇒ nhánh Contracts/Ct_*/Master-set GỠ khỏi revoker.
// Tập ẩn CÒN LẠI = 3 nhóm đang-làm (Hrm/Off/Personal — ý ❸ owner cùng ngày).
private static readonly string[] StillHiddenKeys =
{
MenuKeys.Hrm, // "Hrm" — match StartsWith("Hrm")
MenuKeys.Off, // "Off" — match StartsWith("Off")
MenuKeys.Personal, // "Personal" — match == Personal
};
// [S159] Bộ key S92 cũ nay MỞ LẠI — revoker KHÔNG ĐƯỢC chạm nữa. Giữ danh sách này
// làm REGRESSION-GUARD: bài trả giá 2026-07-29 = grant 38 key bằng SQL tay bị chính
// revoker lật ở app-restart kế (Run #423, 447/494 row rơi, 11/13 role về 0 —
// cicd-verify-1a47a61.md mục 9). Ai re-add nhánh S92 vào predicate ⇒ test này ĐỎ.
private static readonly string[] ReopenedKeys =
{
// HỢP ĐỒNG (Contracts + Ct_*)
MenuKeys.Contracts,
MenuKeys.ContractTypeGroup("ThauPhu"), // "Ct_ThauPhu" — match StartsWith("Ct_")
MenuKeys.ContractTypeGroup("ThauPhu"), // "Ct_ThauPhu"
// 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
MenuKeys.Catalogs,
MenuKeys.CatalogWorkItems,
};
private static readonly string[] S92HiddenKeys =
StillHiddenKeys.Concat(ReopenedKeys).ToArray(); // giữ tên cho seed-helper cũ
// Pe_* (Duyệt NCC) + root PurchaseEvaluations — KHÔNG bị revoke (giữ cho user thường).
private static readonly string[] PeKeptKeys =
{
@ -130,15 +141,22 @@ public class AdminOnlyModulesRevokeTests
await InvokeRevokeAsync(fix);
foreach (var key in S92HiddenKeys)
// Vế 1 — 3 nhóm đang-làm VẪN bị revoke (ý ❸ giữ).
foreach (var key in StillHiddenKeys)
{
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!.CanRead.Should().BeFalse($"{key} bị revoke CanRead — nhóm đang-làm ẩn khỏi non-Admin");
row.CanCreate.Should().BeFalse($"{key} revoke CanCreate");
row.CanUpdate.Should().BeFalse($"{key} revoke CanUpdate");
row.CanDelete.Should().BeFalse($"{key} revoke CanDelete");
}
// Vế 2 — ⭐ REGRESSION-GUARD S159: bộ key MỞ LẠI phải GIỮ NGUYÊN CanRead=true
// sau revoke (revoker không còn nhánh S92; re-add là test này đỏ ngay).
foreach (var key in ReopenedKeys)
(await CanReadAsync(db, nonAdminId, key)).Should().BeTrue(
$"{key} — owner S159 mở lại cho mọi role; revoker KHÔNG được lật grant này nữa");
}
// Admin loại trừ khỏi revoke → giữ nguyên CanRead=true (vẫn thấy 5 nhóm).

View File

@ -189,12 +189,14 @@ public class ProcurementMasterAccessSeedTests
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.
// ⭐ ISOLATION (đọc lại theo S159): revoker KHÔNG còn chạm Master-set ⇒ CanRead=true
// pre-grant của DeptManager GIỮ NGUYÊN (chính sách all-role read — owner 2026-07-29
// supersede S92). Isolation của Procurement-seed nay đo bằng CỜ CAO: seeder chỉ nâng
// CanCreate/Update cho Procurement, KHÔNG rò cờ cao sang role khác.
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");
r: true, c: false, u: false, d: false,
"S159 all-role read GIỮ; cờ cao KHÔNG rò sang DeptManager từ Procurement-grant");
// 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".
@ -246,10 +248,10 @@ public class ProcurementMasterAccessSeedTests
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).
// Isolation bền vững sau lần 2 (S159: CanRead all-role GIỮ true; cờ cao vẫn false).
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");
r: true, c: false, u: false, d: false,
"idempotent — lần seed thứ 2 KHÔNG rò CỜ CAO sang DeptManager (CanRead all-role S159 giữ)");
}
}