[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 { Layout } from '@/components/Layout'
import { LoginPage } from '@/pages/LoginPage' import { LoginPage } from '@/pages/LoginPage'
import { DashboardPage } from '@/pages/DashboardPage' import { DashboardPage } from '@/pages/DashboardPage'
import { ComingSoonPage } from '@/pages/ComingSoonPage'
import { SuppliersPage } from '@/pages/master/SuppliersPage' import { SuppliersPage } from '@/pages/master/SuppliersPage'
import { ProjectsPage } from '@/pages/master/ProjectsPage' import { ProjectsPage } from '@/pages/master/ProjectsPage'
import { DepartmentsPage } from '@/pages/master/DepartmentsPage' import { DepartmentsPage } from '@/pages/master/DepartmentsPage'
@ -55,6 +56,8 @@ function App() {
} }
> >
<Route path="/dashboard" element={<DashboardPage />} /> <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/suppliers" element={<SuppliersPage />} />
<Route path="/master/projects" element={<ProjectsPage />} /> <Route path="/master/projects" element={<ProjectsPage />} />
<Route path="/master/departments" element={<DepartmentsPage />} /> <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. // Ct_<Type>_<Action> sub-menu entries.
function resolvePath(key: string): string | null { function resolvePath(key: string): string | null {
const staticMap: Record<string, string> = { 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', Dashboard: '/dashboard',
Suppliers: '/master/suppliers', Suppliers: '/master/suppliers',
Projects: '/master/projects', 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 { Layout } from '@/components/Layout'
import { LoginPage } from '@/pages/LoginPage' import { LoginPage } from '@/pages/LoginPage'
import { UserDashboardPage } from '@/pages/UserDashboardPage' import { UserDashboardPage } from '@/pages/UserDashboardPage'
import { ComingSoonPage } from '@/pages/ComingSoonPage'
import { InboxPage } from '@/pages/InboxPage' import { InboxPage } from '@/pages/InboxPage'
import { SuppliersPage } from '@/pages/master/SuppliersPage' import { SuppliersPage } from '@/pages/master/SuppliersPage'
import { ProjectsPage } from '@/pages/master/ProjectsPage' import { ProjectsPage } from '@/pages/master/ProjectsPage'
@ -47,6 +48,8 @@ function App() {
} }
> >
<Route path="/dashboard" element={<UserDashboardPage />} /> <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="/inbox" element={<InboxPage />} />
<Route path="/master/suppliers" element={<SuppliersPage />} /> <Route path="/master/suppliers" element={<SuppliersPage />} />
<Route path="/master/projects" element={<ProjectsPage />} /> <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). // /my-contracts (user's own drafts), Duyệt to /inbox (pending THEIR approval).
function resolvePath(key: string): string | null { function resolvePath(key: string): string | null {
const staticMap: Record<string, string> = { 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', Dashboard: '/dashboard',
Contracts: '/my-contracts', Contracts: '/my-contracts',
PurchaseEvaluations: '/purchase-evaluations', 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). // 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.KeHoachKyKet, "Kế hoạch ký kết HĐ", null, 26, "FileSignature"),
(MenuKeys.HopDongCung, "Hợp đồng cứng", null, 32, "Stamp"), (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.Forms, "Biểu mẫu", null, 40, "FileSpreadsheet"),
(MenuKeys.Reports, "Báo cáo", null, 50, "BarChart3"), (MenuKeys.Reports, "Báo cáo", null, 50, "BarChart3"),
(MenuKeys.System, "Hệ thống", null, 90, "Settings"), (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 // Per-type sub-menu under Contracts: 1 group + 3 leaves each
// (Danh sách / Thao tác / Duyệt). // (Danh sách / Thao tác / Duyệt).
var order = 31; var order = 31;
var hdcOrder = 1;
foreach (var code in MenuKeys.ContractTypeCodes) foreach (var code in MenuKeys.ContractTypeCodes)
{ {
var label = typeLabels.GetValueOrDefault(code, code); 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.ContractTypeGroup(code), label, MenuKeys.Contracts, order++, "FileText"));
tree.Add((MenuKeys.ContractTypeList(code), "Danh sách", MenuKeys.ContractTypeGroup(code), order++, "List")); 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")); 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). // → "PeWf_*"/"PeWorkflows" KHÔNG match (loại admin Designer).
static bool IsPeKey(string key) => key.StartsWith("Pe_"); 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 // MenuKeys.All chứa root PurchaseEvaluations nhưng KHÔNG chứa Pe_* leaf
// (sinh động qua factory). Build leaf giống SeedPurchaseEvaluationPermissionDefaultsAsync // (sinh động qua factory). Build leaf giống SeedPurchaseEvaluationPermissionDefaultsAsync
// để upgrade đúng row Pe_* thật trong DB (1 root + 5 leaf × 2 type). // để 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 // [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ụ. // ⇒ nhánh read-only). Prod đã INSERT trực tiếp; đây giữ fresh-DB hội tụ.
.Concat(new[] { MenuKeys.KeHoachKyKet, MenuKeys.HopDongCung }) .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() .Distinct()
.ToArray(); .ToArray();
var roles = await roleManager.Roles.ToListAsync(); var roles = await roleManager.Roles.ToListAsync();
@ -2214,14 +2259,16 @@ public static class DbInitializer
var rows = await db.Permissions var rows = await db.Permissions
.Where(p => p.RoleId != adminRoleId .Where(p => p.RoleId != adminRoleId
&& (p.MenuKey.StartsWith("Hrm") || p.MenuKey.StartsWith("Off") && (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 + // [S159 2026-07-29] 🔴 GỠ nhánh [S92] (Contracts+Ct_* / Master+Suppliers+
// Suppliers + Projects + Departments + Catalog*) — anh chốt "chỉ Admin // Projects+Departments+Catalog*) — owner SUPERSEDE chính quyết định S92
// thấy". Pe_* (Duyệt NCC) KHÔNG nằm đây → module PE giữ nguyên cho user. // "chỉ Admin thấy": nay "đang phát triển → cho hiển thị hết để mọi người
|| p.MenuKey == MenuKeys.Contracts || p.MenuKey.StartsWith("Ct_") // góp ý; Duyệt NCC để như cũ". Bài trả giá cùng ngày: grant 38 key bằng
|| p.MenuKey == MenuKeys.Master || p.MenuKey == MenuKeys.Suppliers // SQL tay bị CHÍNH revoker này lật lại ở app-restart kế (Run #423 —
|| p.MenuKey == MenuKeys.Projects || p.MenuKey == MenuKeys.Departments // 447/494 row rơi, 11/13 role về 0; cicd-verify-1a47a61.md mục 9).
|| p.MenuKey.StartsWith("Catalog")) // Đú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)) && (p.CanRead || p.CanCreate || p.CanUpdate || p.CanDelete))
.ToListAsync(); .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). // (Cascade) → PHẢI seed MenuItem rows + Role TRƯỚC khi seed Permission (nếu không SQLite FK Error 19).
public class AdminOnlyModulesRevokeTests public class AdminOnlyModulesRevokeTests
{ {
// Đại diện 5 nhóm anh chốt admin-only — sau revoke PHẢI CanRead=false (non-Admin). // [S159 2026-07-29] Owner SUPERSEDE S92: "đang phát triển → cho hiển thị hết để mọi
// Dùng const/factory MenuKeys (KHÔNG hardcode string) để bắt đồng-bộ nếu key đổi. // người góp ý; Duyệt NCC để như cũ" ⇒ nhánh Contracts/Ct_*/Master-set GỠ khỏi revoker.
private static readonly string[] S92HiddenKeys = // 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_*) // HỢP ĐỒNG (Contracts + Ct_*)
MenuKeys.Contracts, MenuKeys.Contracts,
MenuKeys.ContractTypeGroup("ThauPhu"), // "Ct_ThauPhu" — match StartsWith("Ct_") MenuKeys.ContractTypeGroup("ThauPhu"), // "Ct_ThauPhu"
// DANH MỤC (Master + 3 leaf + Catalog*) // DANH MỤC (Master + 3 leaf + Catalog*)
MenuKeys.Master, MenuKeys.Master,
MenuKeys.Suppliers, MenuKeys.Suppliers,
MenuKeys.Projects, MenuKeys.Projects,
MenuKeys.Departments, MenuKeys.Departments,
MenuKeys.Catalogs, // "Catalogs" — match StartsWith("Catalog") MenuKeys.Catalogs,
MenuKeys.CatalogWorkItems, // "CatalogWorkItems" — match StartsWith("Catalog") MenuKeys.CatalogWorkItems,
// 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
}; };
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). // Pe_* (Duyệt NCC) + root PurchaseEvaluations — KHÔNG bị revoke (giữ cho user thường).
private static readonly string[] PeKeptKeys = private static readonly string[] PeKeptKeys =
{ {
@ -130,15 +141,22 @@ public class AdminOnlyModulesRevokeTests
await InvokeRevokeAsync(fix); 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); 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.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.CanCreate.Should().BeFalse($"{key} revoke CanCreate");
row.CanUpdate.Should().BeFalse($"{key} revoke CanUpdate"); row.CanUpdate.Should().BeFalse($"{key} revoke CanUpdate");
row.CanDelete.Should().BeFalse($"{key} revoke CanDelete"); 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). // 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 InvokeRevokeAsync(fix); // revoke CẢ HAI role về false
await InvokeSeedProcurementAsync(fix); // CHỈ nâng lại Procurement 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 // ⭐ ISOLATION (đọc lại theo S159): revoker KHÔNG còn chạm Master-set ⇒ CanRead=true
// Procurement KHÔNG chạm role khác. Assert cả Master + Suppliers (2 key task chốt) + 7 key kia. // 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) foreach (var key in AllGrantKeys)
await AssertFlagsAsync(db, otherId, key, await AssertFlagsAsync(db, otherId, key,
r: false, c: false, u: false, d: false, r: true, 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"); "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, // 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". // 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); 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"); 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) foreach (var key in AllGrantKeys)
await AssertFlagsAsync(db, otherId, key, await AssertFlagsAsync(db, otherId, key,
r: false, c: false, u: false, d: false, r: true, c: false, u: false, d: false,
"idempotent — lần seed thứ 2 KHÔNG rò sang DeptManager"); "idempotent — lần seed thứ 2 KHÔNG rò CỜ CAO sang DeptManager (CanRead all-role S159 giữ)");
} }
} }