[CLAUDE] Auth: golive Văn phòng số — public Read+Create module Office cho mọi role (allow-list 16 key) + 6 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m33s

Anh chốt "public văn phòng số cho all user eoffice". Mở quyền Xem + Tạo (self-service) module Office cho mọi role trên cả 2 app.

- NEW SeedAllRolesOfficeModulePermissionsAsync (DbInitializer): grant CanRead+CanCreate=true cho allow-list 16 key Office, chạy SAU RevokeTemporarilyHiddenModulesAsync để THẮNG revoke (mirror đúng pattern S65 HRM public). Upgrade-only: nâng false→true trên row prod đã có, KHÔNG hạ, KHÔNG đụng CanUpdate/CanDelete. No migration (seed-logic, idempotent).
- ALLOW-LIST 16: Off + Off_Dashboard + Off_DanhBa + Off_PhongHop(View/Book) + Off_DeXuat(List/Create/Inbox) + Off_DonTu(Leave/Ot/Travel) + Off_DatXe + Off_ItTicket.
- GIỮ ẨN (ngoài allow-list → revoke vẫn che non-Admin): Off_PhongHop_Manage (admin CRUD phòng), Off_AttendanceReport (báo cáo chấm công — riêng tư), Off_ChamCong (Cá nhân — golive riêng). HRM (trừ Hồ sơ NS S65) + Personal VẪN ẩn (anh chỉ mở Office).
- reviewer PASS 0 blocker (security): cascade-safe (Off KHÔNG phải inherit-root trong GetMyMenuTree → excluded-3 giữ false, không lan); KHÔNG mở write-path thật (Office controller dùng [Authorize] self-service + [Authorize(Roles=Admin)] cho admin-write — CanCreate chỉ mở menu + nút tạo FE, API authz độc lập menu key; quản lý phòng double-protected).
- +6 test OfficeModulePermissionSeedTests (286→292) lock: allow-list read+create=true · excluded-3 stay hidden (load-bearing) · admin not demoted · no-leak HRM/Personal · upgrade-only preserves admin-raised Update/Delete.
- Build slnx 0/0 · dotnet test 292 PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-17 10:33:32 +07:00
parent c556f6cfa2
commit 1f8947e763
5 changed files with 385 additions and 2 deletions

File diff suppressed because one or more lines are too long

View File

@ -61,6 +61,7 @@ Adversarial pre-commit reviewer SOLUTION_ERP. Read-only verify + live curl prod
## 📅 Recent activity (FIFO — older → archive/git) ## 📅 Recent activity (FIFO — older → archive/git)
- **2026-06-17 (S69 GOLIVE Văn phòng số public-all-roles authz — PASS, 0 blocker, gotcha #44-family CLEAN):** 1-file BE-only DbInitializer.cs (+81, new `SeedAllRolesOfficeModulePermissionsAsync` :2261 + call :2055 AFTER S65 HRM grant → AFTER revoke :2042). NOT deployed (static + Dev-DB review, build PASS). Near-exact mirror of S65 HRM method, ONLY delta = `+CanCreate=true` (HRM was read-only). **8 verify ALL PASS:** (1) **Ordering** — grant call sits after `RevokeTemporarilyHiddenModulesAsync` (:2042) + after S65 (:2048) → grant wins revoke. (2) **Allow-list EXACTLY 16 Off keys** — Off/Dashboard/DanhBa/PhongHop(+View+Book)/DeXuat(+List+Create+Inbox)/DonTu(+Leave+Ot+Travel)/DatXe/ItTicket; const names map correct values per MenuKeys.cs:99-120; NO PhongHopManage/AttendanceReport/ChamCong; array contains ZERO Hrm*/Personal/Pe*/Master key → no leak. (3) **Upgrade-only correct** — row exists→only flips CanRead/CanCreate false→true (`if(!row.CanRead)`+`if(!row.CanCreate)`), NEVER touches CanUpdate/CanDelete, never lowers; new row→read+create=true, update/delete=false (Permission.cs defaults false anyway). (4) **3 excluded keys STAY HIDDEN — decisive cascade check:** `Off` is NOT one of the 4 inherit-roots in GetMyMenuTreeQuery (:56-59,:70-73,:80-83 = Contracts/Workflows/PE/PeWorkflows ONLY) → granting Off does NOT cascade to children; each Off child reads its OWN `resolved` flags (:65, falls to false-tuple if no row); PhongHop_Manage(parent=Off_PhongHop:1830)/AttendanceReport(parent=Off:1845) not-in-list→revoke-false→filtered by HasAccess(:96); ChamCong re-parented to Personal(:1850/:1962) under hidden Personal root, not under Off, not granted→hidden. (5) **Admin unharmed** — MenuPermissionHandler:27 Admin bypass; Dev DB: all 18 Off rows belong to Admin already read+create=true → upgrade branch no-op. (6) **No real write-path opened — KEY for golive:** grep Controllers for Off menu keys = 0 matches; Office controllers gate writes by class-level `[Authorize]` (any-auth, self-service create) + per-action `[Authorize(Roles="Admin")]` for true admin writes (MeetingRoomsController Create/Update/Delete=Manage-rooms :26/34/43, Attendances :37/42, LeaveBalances :23/28) — NOT by Off_*.Create policy. So broad CanCreate grant only drives FE menu+button (usePermission/PermissionGuard); API write-auth untouched, admin CRUD stays Admin-only regardless. (7) **No migration** — seed-logic only; all 16 keys in MenuKeys.All:157-161 (seeded). (8) **Idempotent** — 2nd run: rows already true→0 change; SaveChanges gated `if(added>0||upgraded>0)`. **Dev DB baseline** (307 perms,13 roles): 0 non-admin Off rows exist→method takes add-branch for 12 non-admin roles (creates 16 read+create rows each, 3 excluded never added). build Infrastructure 0err/0warn. 0 rogue write (only cicd-monitor/MEMORY.md noise, read-only respected). **Learned:** for a public-grant golive the load-bearing security proof is TWO-fold — (a) cascade-safety = confirm the granted root is NOT an inherit-root (else siblings leak, gotcha #44-family) AND trace excluded keys' ParentKey to a non-granted/hidden parent; (b) write-path-safety = grep that the broadly-granted menu key is NOT used as a controller `[Authorize(Policy=)]` (here Office uses class `[Authorize]`+per-action Roles=Admin, so CanCreate is FE-only — granting it cannot escalate API writes). **surprise:** the "Manage rooms" admin function is double-protected — excluded from allow-list (menu hidden) AND its API is `[Authorize(Roles=Admin)]`; menu-hide alone would've been insufficient but the controller gate makes the broad grant safe even if a key had slipped. Verdict PASS — safe commit+deploy. Tag [s69, office-golive-authz, public-all-roles, inherit-root-no-cascade, off-not-policy-key-fe-only-grant, gotcha44-family-clean, admin-write-double-protected].
- **2026-06-17 (S69 Văn phòng số RE-SKIN static logic-preservation — PASS, 0 blocker):** 10 pages presentation-only re-skin → PURO PageHeader/KpiCard + Hồ sơ-NS idiom (9 fe-user office + 1 fe-admin AttendanceReport). NOT built yet, fe-admin not mirrored (em main next). **Strongest proof = exact API/queryKey diff OLD-vs-NEW byte-identical ALL 8 fe-user pages** (grep `api\.(get|post|put|delete)` + `queryKey:[...]` sorted -u, zero delta): proposals POST /submit + /{kind} · workflow-apps POST /{k}+/submit+PUT /workflow · meeting-bookings POST/DELETE+invalidate · it-tickets PUT /{id}/assign · directory/departments/attendance-report/excel-blob all UNCHANGED. Mutation side-effects (onSuccess/onError/invalidateQueries/setActionDialog/setComment/navigate) 1:1 (line-shift only). ProposalCreate validation `!title.trim()` throw + required + submit-disabled intact. AttendanceReport exportExcel blob (createObjectURL→a.download→click→revoke) intact. **Cat2 orphans CLEAN:** 0 unused import — flagged Users(=UsersIcon alias) + FormEvent/ReactNode (React.* namespace not named-import) + Accent(comment word) all FALSE-alarm verified. **Cat3 shared-comp contract:** PageHeader{eyebrow,title,subtitle,icon,accent,actions} + KpiCard{label,value,icon,accent,active,onClick} props all match real sig; KpiCard onClick wired to REAL filter state (ItTickets `setFilter`/WorkflowAppsList `setStatusFilter`/ProposalsList — driving actual client `.filter()`), InternalDirectory 2 KpiCards INTENTIONALLY inert (no onClick=presentational counts, matches comp design — NOT dummy). **Shared comps + index.css NOT modified** (git status -- ui/ + *.css EMPTY; sha256 identical fe-user==fe-admin per ls). **Cat4 color-trap CLEAN:** grep added lines for `(teal|violet|amberx|greenx)-(200|300|400|800|900)` = ZERO; index.css confirms accents ship only 50/100/500/600/700 (brand has full 50-900 so brand-800 valid); gotcha #66 — 0 gradient/dark-bg headings added (all headers on light surface use accent-ink text-brand-800/{accent}-700 via PageHeader). **Cat1 mock-markers:** 0 //Mock/alert/TODO-wire. **Client-side filter additions** (ItTickets filter/breached, WorkflowAppsList statusFilter useMemo) = presentation views over fetched items, NO new query/endpoint. **2 MINOR (non-block):** (a) ProposalDetail status badge now renders TWICE — PageHeader actions slot + existing status-row (cosmetic dup, both presentation); (b) it-tickets/workflow-apps client-filter is view-only over a `pageSize:100/50` first-page fetch (pre-existing pagination limit, re-skin doesn't worsen). **Learned:** for pure re-skin, the decisive logic-preservation proof is `grep api-call + queryKey sorted -u` OLD-vs-NEW byte-equality across every page — faster + more rigorous than reading each hunk; orphan-import heuristic (body-occ<=1) flags `X as Y` aliases + `React.X` namespace + comment-words as false-positives, always grep the actual usage line before flagging build-break. **surprise:** custom accent palettes (amberx/greenx/teal/violet) deliberately ship NO -800 stop so headings MUST use -700 (brand is the only -800-bearing accent) — a -800 on a non-brand accent = silent no-class Tailwind v4, the re-skin respected this everywhere. Verdict PASS — safe for em main to build+mirror. Tag [s69, office-reskin, presentation-only, api-querykey-byte-equal, color-trap-clean, kpicard-inert-vs-filter, gotcha66-clean]. - **2026-06-17 (S69 Văn phòng số RE-SKIN static logic-preservation — PASS, 0 blocker):** 10 pages presentation-only re-skin → PURO PageHeader/KpiCard + Hồ sơ-NS idiom (9 fe-user office + 1 fe-admin AttendanceReport). NOT built yet, fe-admin not mirrored (em main next). **Strongest proof = exact API/queryKey diff OLD-vs-NEW byte-identical ALL 8 fe-user pages** (grep `api\.(get|post|put|delete)` + `queryKey:[...]` sorted -u, zero delta): proposals POST /submit + /{kind} · workflow-apps POST /{k}+/submit+PUT /workflow · meeting-bookings POST/DELETE+invalidate · it-tickets PUT /{id}/assign · directory/departments/attendance-report/excel-blob all UNCHANGED. Mutation side-effects (onSuccess/onError/invalidateQueries/setActionDialog/setComment/navigate) 1:1 (line-shift only). ProposalCreate validation `!title.trim()` throw + required + submit-disabled intact. AttendanceReport exportExcel blob (createObjectURL→a.download→click→revoke) intact. **Cat2 orphans CLEAN:** 0 unused import — flagged Users(=UsersIcon alias) + FormEvent/ReactNode (React.* namespace not named-import) + Accent(comment word) all FALSE-alarm verified. **Cat3 shared-comp contract:** PageHeader{eyebrow,title,subtitle,icon,accent,actions} + KpiCard{label,value,icon,accent,active,onClick} props all match real sig; KpiCard onClick wired to REAL filter state (ItTickets `setFilter`/WorkflowAppsList `setStatusFilter`/ProposalsList — driving actual client `.filter()`), InternalDirectory 2 KpiCards INTENTIONALLY inert (no onClick=presentational counts, matches comp design — NOT dummy). **Shared comps + index.css NOT modified** (git status -- ui/ + *.css EMPTY; sha256 identical fe-user==fe-admin per ls). **Cat4 color-trap CLEAN:** grep added lines for `(teal|violet|amberx|greenx)-(200|300|400|800|900)` = ZERO; index.css confirms accents ship only 50/100/500/600/700 (brand has full 50-900 so brand-800 valid); gotcha #66 — 0 gradient/dark-bg headings added (all headers on light surface use accent-ink text-brand-800/{accent}-700 via PageHeader). **Cat1 mock-markers:** 0 //Mock/alert/TODO-wire. **Client-side filter additions** (ItTickets filter/breached, WorkflowAppsList statusFilter useMemo) = presentation views over fetched items, NO new query/endpoint. **2 MINOR (non-block):** (a) ProposalDetail status badge now renders TWICE — PageHeader actions slot + existing status-row (cosmetic dup, both presentation); (b) it-tickets/workflow-apps client-filter is view-only over a `pageSize:100/50` first-page fetch (pre-existing pagination limit, re-skin doesn't worsen). **Learned:** for pure re-skin, the decisive logic-preservation proof is `grep api-call + queryKey sorted -u` OLD-vs-NEW byte-equality across every page — faster + more rigorous than reading each hunk; orphan-import heuristic (body-occ<=1) flags `X as Y` aliases + `React.X` namespace + comment-words as false-positives, always grep the actual usage line before flagging build-break. **surprise:** custom accent palettes (amberx/greenx/teal/violet) deliberately ship NO -800 stop so headings MUST use -700 (brand is the only -800-bearing accent) — a -800 on a non-brand accent = silent no-class Tailwind v4, the re-skin respected this everywhere. Verdict PASS — safe for em main to build+mirror. Tag [s69, office-reskin, presentation-only, api-querykey-byte-equal, color-trap-clean, kpicard-inert-vs-filter, gotcha66-clean].
- **2026-06-16 (S65 PE mục E HoSoLink review — em-main PROXY, PE-Workflow reviewer-stage died-empty):** Review mục-E hyperlink render + HoSoLink BE wiring (`5a0aaa4`). Reviewer-stage trong Workflow `pe-hoso-link-rename-pro` return RỖNG → em main self-gate evidence: Detail DTO `hoSoLink` present + `null` backward-compat phiếu thật (Run #293 GET 200); Create/Update +trailing-optional `HoSoLink=null` KHÔNG vỡ call-site (grep 0 manual ctor — KHÁC CreateDepartmentCommand #291 CS7036 vì positional-required vs trailing-optional); mirror fe-user==fe-admin SHA256 IDENTICAL (PeDetailTabs+PeWorkspaceCreateView); hyperlink `<a target=_blank rel=noopener noreferrer>` no reverse-tabnabbing; rename "Dự trù PRO"→"Ngân sách PRO" CHỈ display (giữ "Ghi chú từ PRO" + field-code). LEARNED: hyperlink free-text = no server-side XSS (render-as-href client-only); absolute-set Update (null=clear) chủ đích. SURPRISE: reviewer-stage chết-rỗng trong fan-out = lý do verify-heavy task vẫn cần em-main self-gate dù có Workflow (verdict `feedback_workflow_fanout_reliability`). Tag `[s65, pe-section-e-review, em-main-proxy-self-gate, hosolink-backward-compat, workflow-fanout]`. - **2026-06-16 (S65 PE mục E HoSoLink review — em-main PROXY, PE-Workflow reviewer-stage died-empty):** Review mục-E hyperlink render + HoSoLink BE wiring (`5a0aaa4`). Reviewer-stage trong Workflow `pe-hoso-link-rename-pro` return RỖNG → em main self-gate evidence: Detail DTO `hoSoLink` present + `null` backward-compat phiếu thật (Run #293 GET 200); Create/Update +trailing-optional `HoSoLink=null` KHÔNG vỡ call-site (grep 0 manual ctor — KHÁC CreateDepartmentCommand #291 CS7036 vì positional-required vs trailing-optional); mirror fe-user==fe-admin SHA256 IDENTICAL (PeDetailTabs+PeWorkspaceCreateView); hyperlink `<a target=_blank rel=noopener noreferrer>` no reverse-tabnabbing; rename "Dự trù PRO"→"Ngân sách PRO" CHỈ display (giữ "Ghi chú từ PRO" + field-code). LEARNED: hyperlink free-text = no server-side XSS (render-as-href client-only); absolute-set Update (null=clear) chủ đích. SURPRISE: reviewer-stage chết-rỗng trong fan-out = lý do verify-heavy task vẫn cần em-main self-gate dù có Workflow (verdict `feedback_workflow_fanout_reliability`). Tag `[s65, pe-section-e-review, em-main-proxy-self-gate, hosolink-backward-compat, workflow-fanout]`.
- **2026-06-16 (S65 public Hồ sơ NS read for all roles — static pre-commit, PASS, 0 blocker, gotcha #44 family CLEAN):** 1-file change DbInitializer.cs (+66, call-site :2046 SAU revoke :2040 + new `SeedAllRolesHrmProfileReadPermissionsAsync` :2203). Prod NOT deployed (static review, build PASS đã claim). **7 verify ALL PASS:** (1) **Ordering** — grant gọi SAU `RevokeTemporarilyHiddenModulesAsync` trong SeedAsync → grant thắng (git diff confirms call sits immediately after revoke). (2) **Upgrade path prod-critical** — method MUTATES existing row `if(!row.CanRead){row.CanRead=true;upgraded++}` (EF change-tracked → SaveChanges persists); NOT skip-existing-noop. Correctly fixes S58-class bug (revoke set CanRead=false on prod rows → upgrade flips true). (3) **Scope precise**`hrmKeys = new[]{MenuKeys.Hrm, MenuKeys.HrmHoSo}` EXACTLY 2; NO Hrm_Dashboard/Hrm_Config*/Off*/Personal. `Hrm` is NOT one of 4 inherit-roots (Contracts/Workflows/PE/PeWorkflows in GetMyMenuTree:56-59) so granting Hrm root does NOT cascade to Dashboard/Config children → they keep own false flags → filtered out by `HasAccess(n)=n.CanRead||Children.Any(HasAccess)`. Menu shows Hrm root → Hồ sơ NS leaf ONLY (HrmHoSo ParentKey=Hrm:1806, Dashboard sibling ParentKey=Hrm:1850 stays hidden). (4) **Read-only** — add-path CanCreate/Update/Delete=false; upgrade-path touches ONLY CanRead. (5) **No regression** — Admin bypass at MenuPermissionHandler:27 untouched; revoke unchanged; Off/Personal/Dashboard/Config stay hidden after full seed. (6) **Idempotent** — 2nd run: row.CanRead already true → `if(!row.CanRead)` false → 0 change. (7) **No non-Admin write path**`MenuPermissionHandler` Read→AnyAsync(CanRead) is what GET checks; all 19 EmployeesController write actions (main+5 satellite) require Hrm_HoSo.Create/Update/Delete which grant leaves false → 403. **surprise/monitor-note (NOT a defect, NOT introduced by this change):** HrDashboardController/HrmConfigsController/Attendances/LeaveBalances carry ONLY class-level `[Authorize]` (any-auth, NO per-action Hrm_*.Read policy) — so their data was already reachable by direct URL pre+post S65 (menu-hide ≠ API-lock; S58 revoke comment DbInit:2153-2155 explicitly acknowledged this). S65 does NOT widen it (only touches perm matrix rows Hrm+Hrm_HoSo + menu filter). cicd-monitor must NOT assume "Dashboard hidden in menu"=="dashboard data unreachable". Spec comment said "6 catalog Hrm_Config*" but there are 6 config leaves + Hrm_Config subgroup = 7 keys — cosmetic count, all stay hidden, not a code bug. **Learned:** for menu-key read-grant, verify the granted root is NOT an inherit-root (else cascade leaks siblings) + trace HasAccess filter + confirm leaf ParentKey chains to the visible root; upgrade-path correctness = grep that method MUTATES row (not skip-existing) when a prior revoke pre-set the flag false on prod. Verdict PASS — safe commit. Tag [s65, public-hrm-hoso, upgrade-path-correct, inherit-root-no-cascade, gotcha44-family-clean, menu-only-not-api-lock-monitor-note]. - **2026-06-16 (S65 public Hồ sơ NS read for all roles — static pre-commit, PASS, 0 blocker, gotcha #44 family CLEAN):** 1-file change DbInitializer.cs (+66, call-site :2046 SAU revoke :2040 + new `SeedAllRolesHrmProfileReadPermissionsAsync` :2203). Prod NOT deployed (static review, build PASS đã claim). **7 verify ALL PASS:** (1) **Ordering** — grant gọi SAU `RevokeTemporarilyHiddenModulesAsync` trong SeedAsync → grant thắng (git diff confirms call sits immediately after revoke). (2) **Upgrade path prod-critical** — method MUTATES existing row `if(!row.CanRead){row.CanRead=true;upgraded++}` (EF change-tracked → SaveChanges persists); NOT skip-existing-noop. Correctly fixes S58-class bug (revoke set CanRead=false on prod rows → upgrade flips true). (3) **Scope precise**`hrmKeys = new[]{MenuKeys.Hrm, MenuKeys.HrmHoSo}` EXACTLY 2; NO Hrm_Dashboard/Hrm_Config*/Off*/Personal. `Hrm` is NOT one of 4 inherit-roots (Contracts/Workflows/PE/PeWorkflows in GetMyMenuTree:56-59) so granting Hrm root does NOT cascade to Dashboard/Config children → they keep own false flags → filtered out by `HasAccess(n)=n.CanRead||Children.Any(HasAccess)`. Menu shows Hrm root → Hồ sơ NS leaf ONLY (HrmHoSo ParentKey=Hrm:1806, Dashboard sibling ParentKey=Hrm:1850 stays hidden). (4) **Read-only** — add-path CanCreate/Update/Delete=false; upgrade-path touches ONLY CanRead. (5) **No regression** — Admin bypass at MenuPermissionHandler:27 untouched; revoke unchanged; Off/Personal/Dashboard/Config stay hidden after full seed. (6) **Idempotent** — 2nd run: row.CanRead already true → `if(!row.CanRead)` false → 0 change. (7) **No non-Admin write path**`MenuPermissionHandler` Read→AnyAsync(CanRead) is what GET checks; all 19 EmployeesController write actions (main+5 satellite) require Hrm_HoSo.Create/Update/Delete which grant leaves false → 403. **surprise/monitor-note (NOT a defect, NOT introduced by this change):** HrDashboardController/HrmConfigsController/Attendances/LeaveBalances carry ONLY class-level `[Authorize]` (any-auth, NO per-action Hrm_*.Read policy) — so their data was already reachable by direct URL pre+post S65 (menu-hide ≠ API-lock; S58 revoke comment DbInit:2153-2155 explicitly acknowledged this). S65 does NOT widen it (only touches perm matrix rows Hrm+Hrm_HoSo + menu filter). cicd-monitor must NOT assume "Dashboard hidden in menu"=="dashboard data unreachable". Spec comment said "6 catalog Hrm_Config*" but there are 6 config leaves + Hrm_Config subgroup = 7 keys — cosmetic count, all stay hidden, not a code bug. **Learned:** for menu-key read-grant, verify the granted root is NOT an inherit-root (else cascade leaks siblings) + trace HasAccess filter + confirm leaf ParentKey chains to the visible root; upgrade-path correctness = grep that method MUTATES row (not skip-existing) when a prior revoke pre-set the flag false on prod. Verdict PASS — safe commit. Tag [s65, public-hrm-hoso, upgrade-path-correct, inherit-root-no-cascade, gotcha44-family-clean, menu-only-not-api-lock-monitor-note].

View File

@ -15,7 +15,7 @@ WRITE specialist độc quyền `tests/**`. xUnit + FluentAssertions 7.2 + EF SQ
- ❌ NOT: production code `src/Backend/**` + `fe-*/**` → test reveal bug → REPORT em main, KHÔNG fix - ❌ NOT: production code `src/Backend/**` + `fe-*/**` → test reveal bug → REPORT em main, KHÔNG fix
- ❌ NOT: decide WHAT to test (test plan) → em main + reviewer chốt priority - ❌ NOT: decide WHAT to test (test plan) → em main + reviewer chốt priority
## 📊 Baseline 286 tests = 286 PASS (45 Domain + 241 Infra) ← S67 +23 HRM test-after [DepartmentTreeTests 8 cycle-guard/rollup/orphan + PeHoSoLinkTests 9 absolute-set (⚠spec-drift: HoSoLink gửi null=CLEAR, KHÔNG null-safe như Budget*/WorkItemId) + HrmProfilePermissionSeedTests 6 reflection private-static revoke→seed chain]. **em main PROXY-RECORD** — return truncated #53 (chết lúc update MEMORY), 3 file delivered + `dotnet test` 286 PASS verify-on-disk. Prev 263 (S61 +22 PeWorkItemBudget 14 BudgetPolicy; Domain 58→45 drop Budget module). Pre = 254 (S60). ## 📊 Baseline 292 tests = 292 PASS (45 Domain + 247 Infra) ← S69 +6 Office golive permission-seed (`OfficeModulePermissionSeedTests.cs`, test-after, mirror HrmProfilePermissionSeedTests S67). Prev 286 ← S67 +23 HRM test-after [DepartmentTreeTests 8 cycle-guard/rollup/orphan + PeHoSoLinkTests 9 absolute-set (⚠spec-drift: HoSoLink gửi null=CLEAR, KHÔNG null-safe như Budget*/WorkItemId) + HrmProfilePermissionSeedTests 6 reflection private-static revoke→seed chain]. **em main PROXY-RECORD** — return truncated #53 (chết lúc update MEMORY), 3 file delivered + `dotnet test` 286 PASS verify-on-disk. Prev 263 (S61 +22 PeWorkItemBudget 14 BudgetPolicy; Domain 58→45 drop Budget module). Pre = 254 (S60).
> Pattern S67: private-static seed/init → invoke qua REFLECTION (`GetMethod(name, NonPublic|Static)` + `Invoke(null, [db, roleManager, NullLogger.Instance])`); seed MenuItem rows TRƯỚC Permission (FK MenuKey→MenuItem.Key Cascade, SQLite Error 19 nếu thiếu). Cycle-guard test: SqliteDbFixture đủ (no User); rollup-count test cần IdentityFixture (đếm User.DepartmentId active). > Pattern S67: private-static seed/init → invoke qua REFLECTION (`GetMethod(name, NonPublic|Static)` + `Invoke(null, [db, roleManager, NullLogger.Instance])`); seed MenuItem rows TRƯỚC Permission (FK MenuKey→MenuItem.Key Cascade, SQLite Error 19 nếu thiếu). Cycle-guard test: SqliteDbFixture đủ (no User); rollup-count test cần IdentityFixture (đếm User.DepartmentId active).
Run: `dotnet test SolutionErp.slnx --nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1` (MSBuild OOM → serialize build) Run: `dotnet test SolutionErp.slnx --nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1` (MSBuild OOM → serialize build)
@ -54,6 +54,8 @@ Test theo CODE (single source truth), document mismatch header comment + report.
## 📅 Recent activity (last 10 FIFO) ## 📅 Recent activity (last 10 FIFO)
- **2026-06-17 (S69 Office golive permission-seed regression test-after SECURITY invariant, public Văn phòng số):** +6 test `tests/.../Application/OfficeModulePermissionSeedTests.cs` **286→292 PASS** (45 Domain + Infra 241247, 0 fail). Mirror `HrmProfilePermissionSeedTests` (S67) SAME reflection harness (invoke 2 private-static `RevokeTemporarilyHiddenModulesAsync` + `SeedAllRolesOfficeModulePermissionsAsync` qua `GetMethod(name, NonPublic|Static).Invoke(null, [db, rm, NullLogger.Instance])`; SqliteDbFixture/IdentityFixture; seed MenuItem rows TRƯỚC Permission FK Cascade). **KHÁC HRM:** Office grant mở **CanRead AND CanCreate** (HRM read-only) trên allow-list **16 key**; HRM chỉ 2 key. Chain = revoke (StartsWith("Off")→all false non-Admin) office-grant (allow-listread+create, upgrade-only). **Cover:** (1) chain non-Admin allow-list-16 read+create=true + **excluded-3 stay hidden** (`OffPhongHopManage`/`OffAttendanceReport`/`OffChamCong` LOAD-BEARING security assert) / (2) allow-list Update+Delete stay false / (3) no-leak HRM-dashboard+Personal stay hidden / (4) Admin not-revoked keeps all incl excluded-3 / (5) create-missing-row read+create=true update/delete=false + excluded NOT created / (6) **upgrade-only preserves admin-raised Update/Delete=true** (office-grant chỉ đụng Read/Create, KHÔNG hạ). **No prod bug** seed logic đúng spec (excluded-3 confirmed hidden, upgrade-only không phá quyền admin). Tag [s69, office-golive, permission-seed, security-invariant, excluded-3-hidden, read+create-grant, upgrade-only, reflection-private-static, test-after].
- **2026-06-12 (S60 UAT anh Kiệt 2 feature PE submit branch, test-after build PASS):** +14 test `tests/.../Services/PeSubmitGuardAndBypassTests.cs` **240→254 PASS** (58 Domain + Infra 182196, 0 fail). Mirror `PurchaseEvaluationWorkflowServiceGuardTests` (IdentityFixture+SQLite, reuse `NoOpNotificationService` internal). **F1 Section 3 guard (8):** submit branch (DangSoanThao/TraLaiChoDuyet) build `missing` list 4 mục ConflictException msg gộp prefix `'Chưa đủ thông tin mục 3 "Đơn vị NCC/TP được chọn"...'` + join `' · '`. Cover: thiếu cả 4 / winner-only / winner+quote=0 / budget (cả null+manual=0) / comparison / **attachment gắn NCC (PES_Id!=null) KHÔNG đếm bảng so sánh = vẫn Conflict** (predicate PES_Id==null) / đủ-4-manual-budgetChoDuyet / đủ-4-BudgetIdChoDuyet. **F2 drafter-bypass (6, V2-only `ApplyDrafterBypassOnSubmitAsync`):** k=drafterSlots.Max(Order) bước đầu auto Cấp 1..k. Cover: drafter=TP(2/2)+2bước→StepIdx=1/Lvl=1+opinion 1 row slot TP+2 AutoApprove / drafter=NV(1/2)→Lvl=2 cùng bước+opinion slot NV / drafter ngoài bước đầuKHÔNG bypass StepIdx=0 Lvl=1 0-auto / 1-bước+drafter cấp cuốiDaDuyet pointers null SLA null / V1(awId null)→submit OK no-bypass no-crash / TraLai-resubmitbypass áp lại opinion UPSERT 1 row + approval cộng dồn 2 vết. ** GUARD-FIRST:** mọi bypass-test PHẢI dựng PE đủ 4 ĐK Section 3 (winner+quote>0+manual-budget+comparison-attach) qua guard. **Seed pattern S60:** `SeedWinnerWithQuoteAsync`(PES+Detail+Quote ThanhTien) map winner→quote sum · `SeedComparisonAttachment`(PES_Id=null) · `SeedWorkflowAsync(Guid[][] stepApprovers)` build multi-step V2 1-NV/cấp. **Opinion-only-ownSlot invariant:** bypass cấp NV skip KHÔNG ghi opinion (chỉ Approval AutoApprove + Changelog vết); assert `opinions.HaveCount(1)` + `ApprovalWorkflowLevelId==drafterSlot.Id`. **No prod bug** — code đúng spec, test theo CODE (S34 rule). Tag [s60, pe-submit-guard, section3-completeness, drafter-bypass, v2-only, guard-first, opinion-ownslot-only, test-after]. - **2026-06-12 (S60 UAT anh Kiệt 2 feature PE submit branch, test-after build PASS):** +14 test `tests/.../Services/PeSubmitGuardAndBypassTests.cs` **240→254 PASS** (58 Domain + Infra 182196, 0 fail). Mirror `PurchaseEvaluationWorkflowServiceGuardTests` (IdentityFixture+SQLite, reuse `NoOpNotificationService` internal). **F1 Section 3 guard (8):** submit branch (DangSoanThao/TraLaiChoDuyet) build `missing` list 4 mục ConflictException msg gộp prefix `'Chưa đủ thông tin mục 3 "Đơn vị NCC/TP được chọn"...'` + join `' · '`. Cover: thiếu cả 4 / winner-only / winner+quote=0 / budget (cả null+manual=0) / comparison / **attachment gắn NCC (PES_Id!=null) KHÔNG đếm bảng so sánh = vẫn Conflict** (predicate PES_Id==null) / đủ-4-manual-budgetChoDuyet / đủ-4-BudgetIdChoDuyet. **F2 drafter-bypass (6, V2-only `ApplyDrafterBypassOnSubmitAsync`):** k=drafterSlots.Max(Order) bước đầu auto Cấp 1..k. Cover: drafter=TP(2/2)+2bước→StepIdx=1/Lvl=1+opinion 1 row slot TP+2 AutoApprove / drafter=NV(1/2)→Lvl=2 cùng bước+opinion slot NV / drafter ngoài bước đầuKHÔNG bypass StepIdx=0 Lvl=1 0-auto / 1-bước+drafter cấp cuốiDaDuyet pointers null SLA null / V1(awId null)→submit OK no-bypass no-crash / TraLai-resubmitbypass áp lại opinion UPSERT 1 row + approval cộng dồn 2 vết. ** GUARD-FIRST:** mọi bypass-test PHẢI dựng PE đủ 4 ĐK Section 3 (winner+quote>0+manual-budget+comparison-attach) qua guard. **Seed pattern S60:** `SeedWinnerWithQuoteAsync`(PES+Detail+Quote ThanhTien) map winner→quote sum · `SeedComparisonAttachment`(PES_Id=null) · `SeedWorkflowAsync(Guid[][] stepApprovers)` build multi-step V2 1-NV/cấp. **Opinion-only-ownSlot invariant:** bypass cấp NV skip KHÔNG ghi opinion (chỉ Approval AutoApprove + Changelog vết); assert `opinions.HaveCount(1)` + `ApprovalWorkflowLevelId==drafterSlot.Id`. **No prod bug** — code đúng spec, test theo CODE (S34 rule). Tag [s60, pe-submit-guard, section3-completeness, drafter-bypass, v2-only, guard-first, opinion-ownslot-only, test-after].
- **2026-06-11 (S57bis P2 PE WorkItemId guard Mig 49 — test-after, code đã đúng sẵn):** +12 test `tests/.../Application/PeWorkItemGuardTests.cs`**228→240 PASS** (58 Domain + Infra 170→182, 0 fail). PE `Guid? WorkItemId` loose-Guid (KHÔNG FK vật lý, convention giống ProjectId). **Cover-map 3 trục:** (1) **Validator ×3**`CreatePurchaseEvaluationCommandValidator.Validate(cmd)` plain API (KHÔNG có FluentValidation.TestHelper package): null→invalid+error trên WorkItemId / present→valid. (2) **Create-FK-guard ×4** — handler 4-dep instantiate THẬT trên SQLite (`new PurchaseEvaluationWorkflowService(db,dt,notify,um)` + `new PurchaseEvaluationCodeGenerator(db,dt)` — Serializable-tx non-issue SQLite proven S52; reuse `NoOpNotificationService` internal từ ...Services ns + IdentityFixture): bogus-Guid→Conflict / inactive→Conflict / active→OK+`saved.WorkItemId==active.Id`. (3) **Update-null-safe ×5 (bug-class S42 picker)**`UpdatePurchaseEvaluationDraftCommandHandler(db,cu)` 2-dep nhẹ: request.WorkItemId=null→GIỮ w1 KHÔNG null-hoá (⭐ core) / W2-active→đổi / bogus→Conflict+giữ w1 (AsNoTracking re-read DB-truth) / inactive→Conflict / same-as-existing→skip-lookup-success. **⚠️ SPEC-DRIFT FOUND (test theo CODE, S34 rule):** `NotEmpty()` trên `Guid?` (nullable) chỉ bắt `null`, KHÔNG bắt `Guid.Empty` (FV 7.2 so default(Guid?)==null) → Guid.Empty PASS validator. KHÔNG phải lỗ hổng — create handler FK-guard (`is Guid wiId` true cho Empty + AnyAsync false) chặn → Conflict. Test LOCK behavior (1 validator-test assert Empty pass + 1 handler-test chứng minh defense-in-depth catch). REPORT em main: validator một mình không reject Guid.Empty, dựa handler. No prod bug — code đúng spec, defense-in-depth layered. Tag [s57bis, p2, pe-workitemid, mig49, validator-plain-api, null-safe-partial-update, guid-empty-nullable-notempty-drift, defense-in-depth]. - **2026-06-11 (S57bis P2 PE WorkItemId guard Mig 49 — test-after, code đã đúng sẵn):** +12 test `tests/.../Application/PeWorkItemGuardTests.cs`**228→240 PASS** (58 Domain + Infra 170→182, 0 fail). PE `Guid? WorkItemId` loose-Guid (KHÔNG FK vật lý, convention giống ProjectId). **Cover-map 3 trục:** (1) **Validator ×3**`CreatePurchaseEvaluationCommandValidator.Validate(cmd)` plain API (KHÔNG có FluentValidation.TestHelper package): null→invalid+error trên WorkItemId / present→valid. (2) **Create-FK-guard ×4** — handler 4-dep instantiate THẬT trên SQLite (`new PurchaseEvaluationWorkflowService(db,dt,notify,um)` + `new PurchaseEvaluationCodeGenerator(db,dt)` — Serializable-tx non-issue SQLite proven S52; reuse `NoOpNotificationService` internal từ ...Services ns + IdentityFixture): bogus-Guid→Conflict / inactive→Conflict / active→OK+`saved.WorkItemId==active.Id`. (3) **Update-null-safe ×5 (bug-class S42 picker)**`UpdatePurchaseEvaluationDraftCommandHandler(db,cu)` 2-dep nhẹ: request.WorkItemId=null→GIỮ w1 KHÔNG null-hoá (⭐ core) / W2-active→đổi / bogus→Conflict+giữ w1 (AsNoTracking re-read DB-truth) / inactive→Conflict / same-as-existing→skip-lookup-success. **⚠️ SPEC-DRIFT FOUND (test theo CODE, S34 rule):** `NotEmpty()` trên `Guid?` (nullable) chỉ bắt `null`, KHÔNG bắt `Guid.Empty` (FV 7.2 so default(Guid?)==null) → Guid.Empty PASS validator. KHÔNG phải lỗ hổng — create handler FK-guard (`is Guid wiId` true cho Empty + AnyAsync false) chặn → Conflict. Test LOCK behavior (1 validator-test assert Empty pass + 1 handler-test chứng minh defense-in-depth catch). REPORT em main: validator một mình không reject Guid.Empty, dựa handler. No prod bug — code đúng spec, defense-in-depth layered. Tag [s57bis, p2, pe-workitemid, mig49, validator-plain-api, null-safe-partial-update, guid-empty-nullable-notempty-drift, defense-in-depth].
- **2026-06-09 (S56 GOLIVE-HARDEN TEST stage — 4 pre-golive fixes, test-after build):** +12 test → **216→228 PASS** (58 Domain + Infra 158→170, 0 fail). Build stage đã land prod fixes (CONTRACT từ build, signatures UNCHANGED). **#3 LeaveBalance lost-update fix:** handler terminal nay increment `db.LeaveBalances.Where(...).ExecuteUpdateAsync(s=>s.SetProperty(b=>b.UsedDays, b=>b.UsedDays+p.NumDays))` server-side + 1 explicit tx (READ COMMITTED, NO IsolationLevel). **⭐ GOTCHA: ExecuteUpdateAsync BYPASS change tracker** → instance bal tracked (Add STEP1 hoặc pre-seed cùng context) GIỮ UsedDays PRE-increment. **4 test cũ LeaveBalanceTests (case 1/2/3/4 line 163/201/240/269) FAIL ở baseline = stale-tracked-read, KHÔNG regression** (spec TEST GUIDANCE đã tiên đoán). Fix = `.AsNoTracking()` re-read (hoặc `ChangeTracker.Clear()`). +2 new: `TwoSeparateRequests_BothTerminal_UsedDaysAccumulates_NotOverwrites` (3+5=8 chứng minh increment accumulate KHÔNG overwrite = race-free invariant) + `Approve_AlreadyDaDuyet_ReApprove_ThrowsConflict_NoDoubleDeduct` (early guard Status!=DaGuiDuyet:296 → exactly-once, balance vẫn 3 not 6). **#4 Travel/Vehicle ApproveV2 smoke (WorkflowAppApproveV2Tests.cs +4):** mỗi module Submit→Approve→DaDuyet happy + outsider→Forbidden. ApplicableType Travel=9 prefix `DT/CT`, Vehicle=7 prefix `DX/XE`. Travel/Vehicle KHÔNG trừ balance → không seed LeaveType. Helper mới `SeedWorkflowForTypeAsync(type,code,...approverIds)`. **#5 ItTicket existence-oracle (ItTicketReassignAuthzTests.cs +2):** authz reorder (Forbidden TRƯỚC NotFound) — non-IT non-admin nhận Forbidden cho ticketId tồn tại VÀ không tồn tại (cặp 5b/5c phản hồi giống nhau = no oracle leak). Reorder KHÔNG vỡ test cũ (Case5 đã expect Forbidden; TicketNotFound dùng Admin caller pass authz hợp lệ). **#6 DocxRenderer (Forms/DocxRendererTests.cs NEW +4):** 0 test trước đó. MainDocumentPart null→`InvalidOperationException("*MainDocumentPart*")` (OpenXml 3.5.1 `WordprocessingDocument.Create(path,type)` tạo package RỖNG no main part) + placeholder replace happy + unknown-key giữ literal + null-value→empty. **⚠️ test helper ExtractBodyText: tránh `MainPart!.Document.Body!` (CS8602 warning) → dùng `?.Document?.Body` + `.Should().NotBeNull()`.** No prod bug found — tất cả fixes là build-stage, tôi WRITE test theo CONTRACT. Tag [s56, golive-harden, executeupdate-tracker-bypass, asnotracking-reread, travel-vehicle-smoke, existence-oracle, docxrenderer]. - **2026-06-09 (S56 GOLIVE-HARDEN TEST stage — 4 pre-golive fixes, test-after build):** +12 test → **216→228 PASS** (58 Domain + Infra 158→170, 0 fail). Build stage đã land prod fixes (CONTRACT từ build, signatures UNCHANGED). **#3 LeaveBalance lost-update fix:** handler terminal nay increment `db.LeaveBalances.Where(...).ExecuteUpdateAsync(s=>s.SetProperty(b=>b.UsedDays, b=>b.UsedDays+p.NumDays))` server-side + 1 explicit tx (READ COMMITTED, NO IsolationLevel). **⭐ GOTCHA: ExecuteUpdateAsync BYPASS change tracker** → instance bal tracked (Add STEP1 hoặc pre-seed cùng context) GIỮ UsedDays PRE-increment. **4 test cũ LeaveBalanceTests (case 1/2/3/4 line 163/201/240/269) FAIL ở baseline = stale-tracked-read, KHÔNG regression** (spec TEST GUIDANCE đã tiên đoán). Fix = `.AsNoTracking()` re-read (hoặc `ChangeTracker.Clear()`). +2 new: `TwoSeparateRequests_BothTerminal_UsedDaysAccumulates_NotOverwrites` (3+5=8 chứng minh increment accumulate KHÔNG overwrite = race-free invariant) + `Approve_AlreadyDaDuyet_ReApprove_ThrowsConflict_NoDoubleDeduct` (early guard Status!=DaGuiDuyet:296 → exactly-once, balance vẫn 3 not 6). **#4 Travel/Vehicle ApproveV2 smoke (WorkflowAppApproveV2Tests.cs +4):** mỗi module Submit→Approve→DaDuyet happy + outsider→Forbidden. ApplicableType Travel=9 prefix `DT/CT`, Vehicle=7 prefix `DX/XE`. Travel/Vehicle KHÔNG trừ balance → không seed LeaveType. Helper mới `SeedWorkflowForTypeAsync(type,code,...approverIds)`. **#5 ItTicket existence-oracle (ItTicketReassignAuthzTests.cs +2):** authz reorder (Forbidden TRƯỚC NotFound) — non-IT non-admin nhận Forbidden cho ticketId tồn tại VÀ không tồn tại (cặp 5b/5c phản hồi giống nhau = no oracle leak). Reorder KHÔNG vỡ test cũ (Case5 đã expect Forbidden; TicketNotFound dùng Admin caller pass authz hợp lệ). **#6 DocxRenderer (Forms/DocxRendererTests.cs NEW +4):** 0 test trước đó. MainDocumentPart null→`InvalidOperationException("*MainDocumentPart*")` (OpenXml 3.5.1 `WordprocessingDocument.Create(path,type)` tạo package RỖNG no main part) + placeholder replace happy + unknown-key giữ literal + null-value→empty. **⚠️ test helper ExtractBodyText: tránh `MainPart!.Document.Body!` (CS8602 warning) → dùng `?.Document?.Body` + `.Should().NotBeNull()`.** No prod bug found — tất cả fixes là build-stage, tôi WRITE test theo CONTRACT. Tag [s56, golive-harden, executeupdate-tracker-bypass, asnotracking-reread, travel-vehicle-smoke, existence-oracle, docxrenderer].

View File

@ -2046,6 +2046,13 @@ public static class DbInitializer
// user thường tra cứu hồ sơ. Dashboard NS (Hrm_Dashboard) + 6 catalog // user thường tra cứu hồ sơ. Dashboard NS (Hrm_Dashboard) + 6 catalog
// Hrm_Config* VẪN ẨN (revoke che). Read-only (chỉ CanRead). // Hrm_Config* VẪN ẨN (revoke che). Read-only (chỉ CanRead).
await SeedAllRolesHrmProfileReadPermissionsAsync(db, roleManager, logger); await SeedAllRolesHrmProfileReadPermissionsAsync(db, roleManager, logger);
// [S69 2026-06-17] GOLIVE Văn phòng số: mở Read+Create module Office (self-service)
// cho MỌI role — anh chốt "public văn phòng số cho all user eoffice". CHẠY SAU revoke
// để THẮNG (mirror S65). Allow-list 16 key (loại Off_PhongHop_Manage admin-CRUD +
// Off_AttendanceReport báo-cáo-riêng-tư + Off_ChamCong Cá-nhân — giữ ẩn). HRM (trừ
// Hồ sơ NS) + Personal VẪN ẩn (anh chỉ mở Office).
await SeedAllRolesOfficeModulePermissionsAsync(db, roleManager, logger);
} }
// [S57] Cấp CanRead (CHỈ xem) cho MỌI role trên menu HRM + Office + Master để mọi // [S57] Cấp CanRead (CHỈ xem) cho MỌI role trên menu HRM + Office + Master để mọi
@ -2251,6 +2258,80 @@ public static class DbInitializer
} }
} }
// [S69 2026-06-17] GOLIVE Văn phòng số: mở quyền XEM + TẠO (Read+Create) module Office
// cho MỌI role — anh chốt "public văn phòng số cho all user eoffice". Self-service tạo
// phiếu (đề xuất / đơn từ / đặt xe / ticket / đặt phòng) giống Pe (S57bis read+create).
// CHẠY SAU RevokeTemporarilyHiddenModulesAsync (SeedAsync) để THẮNG revoke (mirror S65
// HRM): revoke set mọi Off* = false cho non-Admin; method này nâng RIÊNG allow-list.
// - ALLOW (read+create, 16 key): Off root + Dashboard + DanhBa + PhongHop(View/Book) +
// DeXuat(List/Create/Inbox) + DonTu(Leave/Ot/Travel) + DatXe + ItTicket.
// - GIỮ ẨN (KHÔNG trong allow-list → revoke vẫn che cho non-Admin): Off_PhongHop_Manage
// (Admin CRUD phòng), Off_AttendanceReport (báo cáo admin — riêng tư), Off_ChamCong
// (re-parent Cá nhân — golive riêng). HRM (trừ Hồ sơ NS S65) + Personal VẪN ẩn.
// - Write thật (quản lý phòng/duyệt phiếu) vẫn khóa ở controller theo role; CanCreate chỉ
// mở menu + nút tạo self-service (API self-service = [Authorize] thường).
// - UPGRADE-ONLY (mirror Pe S57bis / HRM S65): row đã tồn tại (revoke vừa set false trên
// prod) → NÂNG CanRead+CanCreate=true. Row chưa có → tạo read+create=true, Update/Delete
// =false. KHÔNG hạ + KHÔNG đụng Update/Delete (không phá quyền admin đã chỉnh cao hơn).
// Flip ẩn lại khi cần: xóa call ở SeedAsync — revoke sẽ tự che lại lần seed kế.
private static async Task SeedAllRolesOfficeModulePermissionsAsync(
ApplicationDbContext db, RoleManager<Role> roleManager, ILogger logger)
{
// Allow-list self-service Văn phòng số (KHÔNG Manage/AttendanceReport/ChamCong).
var officeKeys = new[]
{
MenuKeys.Off, MenuKeys.OffDashboard, MenuKeys.OffDanhBa,
MenuKeys.OffPhongHop, MenuKeys.OffPhongHopView, MenuKeys.OffPhongHopBook,
MenuKeys.OffDeXuat, MenuKeys.OffDeXuatList, MenuKeys.OffDeXuatCreate, MenuKeys.OffDeXuatInbox,
MenuKeys.OffDonTu, MenuKeys.OffDonTuLeave, MenuKeys.OffDonTuOt, MenuKeys.OffDonTuTravel,
MenuKeys.OffDatXe, MenuKeys.OffItTicket,
};
var roles = await roleManager.Roles.ToListAsync();
var existingRows = (await db.Permissions
.Where(p => officeKeys.Contains(p.MenuKey))
.ToListAsync())
.ToDictionary(p => (p.RoleId, p.MenuKey));
var added = 0;
var upgraded = 0;
foreach (var role in roles)
{
foreach (var key in officeKeys)
{
if (existingRows.TryGetValue((role.Id, key), out var row))
{
// Upgrade-only: nâng CanRead/CanCreate nếu đang false (revoke vừa set false).
var changed = false;
if (!row.CanRead) { row.CanRead = true; changed = true; }
if (!row.CanCreate) { row.CanCreate = true; changed = true; }
if (changed) upgraded++;
continue;
}
db.Permissions.Add(new Permission
{
RoleId = role.Id,
MenuKey = key,
CanRead = true,
CanCreate = true,
CanUpdate = false,
CanDelete = false,
});
added++;
}
}
if (added > 0 || upgraded > 0)
{
await db.SaveChangesAsync();
logger.LogInformation(
"Seeded all-roles Office module perms: {Added} added + {Upgraded} upgraded " +
"(Văn phòng số read+create — golive S69)",
added, upgraded);
}
}
// [Plan CA S29 2026-05-22] Permission defaults cho role CatalogManager. // [Plan CA S29 2026-05-22] Permission defaults cho role CatalogManager.
// Strategy: full CRUD trên 9 menu key danh mục dùng chung: // Strategy: full CRUD trên 9 menu key danh mục dùng chung:
// - Master (root group) + Suppliers + Projects + Departments // - Master (root group) + Suppliers + Projects + Departments

View File

@ -0,0 +1,297 @@
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;
// S69 test-after (UAT mode — GOLIVE "public Văn phòng số cho all user eoffice" shipped S69).
// Mirror HrmProfilePermissionSeedTests (S65/S66) — same reflection harness, but Office grant
// mở READ + CREATE (self-service tạo phiếu) trên allow-list 16 key, KHÁC HRM chỉ READ 2 key.
//
// DbInitializer permission-seed chain (SeedAsync order, line 2042-2055):
// 1. RevokeTemporarilyHiddenModulesAsync (S58, :2166) → set MỌI Hrm*/Off*/Personal = false
// cho non-Admin (StartsWith("Off") bắt cả Off, Off_Dashboard, Off_PhongHop_Manage, …).
// 2. SeedAllRolesOfficeModulePermissionsAsync (S69, :2277) → CHẠY SAU để THẮNG revoke,
// nâng RIÊNG allow-list 16 key = CanRead+CanCreate=true (upgrade-only). Excluded 3 key
// (Off_PhongHop_Manage / Off_AttendanceReport / Off_ChamCong) KHÔNG nâng → revoke vẫn che.
//
// Kết quả mong đợi (anh chốt public Văn phòng số self-service, ẩn admin-manage + report + chấm công):
// - non-Admin role: 16 allow-list key → CanRead=true AND CanCreate=true (mở menu + nút tạo).
// - non-Admin role: 3 excluded key → CanRead=false (revoke che, seed không nâng) ← LOAD-BEARING.
// - non-Admin role: CanUpdate/CanDelete trên allow-list VẪN false (grant chỉ mở read+create).
// - non-Admin role: HRM (trừ Hồ sơ NS S65) + Personal VẪN ẩn (Office grant không đụng).
// - Admin role: KHÔNG bị revoke (giữ nguyên — quản trị/CRUD phòng/duyệt phiếu).
//
// 2 method `private static` trong DbInitializer → invoke qua REFLECTION (BindingFlags.NonPublic
// |Static). Test ĐÚNG behavior code thật, KHÔNG re-implement. Signature cả 2:
// (ApplicationDbContext db, RoleManager<Role> roleManager, ILogger logger).
//
// FK lưu ý (PermissionConfiguration): Permission.MenuKey → MenuItem.Key (Cascade) +
// Permission.RoleId → Role (Cascade). → PHẢI seed MenuItem rows + Role TRƯỚC khi seed
// Permission rows (nếu không SQLite FK Error 19).
public class OfficeModulePermissionSeedTests
{
// Allow-list 16 key — sau chain PHẢI có CanRead=true AND CanCreate=true (non-Admin).
private static readonly string[] AllowListKeys =
{
MenuKeys.Off, MenuKeys.OffDashboard, MenuKeys.OffDanhBa,
MenuKeys.OffPhongHop, MenuKeys.OffPhongHopView, MenuKeys.OffPhongHopBook,
MenuKeys.OffDeXuat, MenuKeys.OffDeXuatList, MenuKeys.OffDeXuatCreate, MenuKeys.OffDeXuatInbox,
MenuKeys.OffDonTu, MenuKeys.OffDonTuLeave, MenuKeys.OffDonTuOt, MenuKeys.OffDonTuTravel,
MenuKeys.OffDatXe, MenuKeys.OffItTicket,
};
// Excluded 3 key — sau chain PHẢI GIỮ CanRead=false (revoke che, office-grant KHÔNG đụng).
// Đây là security invariant trọng tâm: admin-manage / báo cáo / chấm công KHÔNG public.
private static readonly string[] ExcludedOfficeKeys =
{
MenuKeys.OffPhongHopManage, // "Off_PhongHop_Manage" — Admin CRUD phòng họp
MenuKeys.OffAttendanceReport, // "Off_AttendanceReport" — báo cáo chấm công (admin)
MenuKeys.OffChamCong, // "Off_ChamCong" — chấm công GPS (re-parent Cá nhân, golive riêng)
};
// HRM (non-profile) + Personal — phải GIỮ ẩn sau Office grant (cross-module leak check).
// Hrm_HoSo cố ý KHÔNG đưa vào đây (S65 đã public riêng — Office chain không touch nó).
private static readonly string[] OtherModuleHiddenKeys =
{
MenuKeys.HrmDashboard,
MenuKeys.Personal,
};
// Tất cả MenuItem key cần seed (FK target cho Permission.MenuKey).
private static readonly string[] AllMenuKeys =
AllowListKeys
.Concat(ExcludedOfficeKeys)
.Concat(OtherModuleHiddenKeys)
.ToArray();
private static async Task InvokeRevokeAsync(IdentityFixture fix)
=> await InvokePrivateSeedAsync(fix, "RevokeTemporarilyHiddenModulesAsync");
private static async Task InvokeOfficeSeedAsync(IdentityFixture fix)
=> await InvokePrivateSeedAsync(fix, "SeedAllRolesOfficeModulePermissionsAsync");
// Reflection invoke private static (ApplicationDbContext, RoleManager<Role>, ILogger).
private static async Task InvokePrivateSeedAsync(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;
}
// Ensure roles tồn tại (Admin + non-Admin). Trả (adminId, nonAdminId).
private static async Task<(Guid adminId, Guid nonAdminId)> SeedRolesAsync(IdentityFixture fix)
{
var rm = fix.Services.GetRequiredService<RoleManager<Role>>();
var admin = new Role { Id = Guid.NewGuid(), Name = AppRoles.Admin };
var drafter = new Role { Id = Guid.NewGuid(), Name = AppRoles.Drafter };
await rm.CreateAsync(admin);
await rm.CreateAsync(drafter);
return (admin.Id, drafter.Id);
}
// Seed MenuItem rows (FK target cho Permission.MenuKey).
private static async Task SeedMenuItemsAsync(TestApplicationDbContext db)
{
foreach (var key in AllMenuKeys)
db.MenuItems.Add(new MenuItem { Key = key, Label = key });
await db.SaveChangesAsync(CancellationToken.None);
}
// Seed 1 Permission row (read-only mặc định — mô phỏng seed cũ trước revoke).
private static void AddPerm(TestApplicationDbContext db, Guid roleId, string menuKey, bool canRead)
=> db.Permissions.Add(new Permission
{
RoleId = roleId,
MenuKey = menuKey,
CanRead = canRead,
CanCreate = false,
CanUpdate = false,
CanDelete = false,
});
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<bool> CanReadAsync(TestApplicationDbContext db, Guid roleId, string menuKey)
=> (await GetPermAsync(db, roleId, menuKey))?.CanRead ?? false;
// ============================================================
// Full chain: pre-grant Off* → Revoke → OfficeSeed (đúng SeedAsync order)
// ============================================================
[Fact]
public async Task Chain_NonAdmin_AllowList16_ReadAndCreateTrue_Excluded3_StayHidden()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (adminId, nonAdminId) = await SeedRolesAsync(fix);
await SeedMenuItemsAsync(db);
// PRE-STATE: non-Admin từng được grant CanRead=true trên MỌI Off* (mô phỏng seed cũ)
// → để revoke có gì đó để thu hồi (gồm cả 3 excluded). Admin cũng có (KHÔNG bị revoke).
foreach (var key in AllowListKeys.Concat(ExcludedOfficeKeys))
{
AddPerm(db, nonAdminId, key, canRead: true);
AddPerm(db, adminId, key, canRead: true);
}
await db.SaveChangesAsync(CancellationToken.None);
// CHAIN đúng thứ tự SeedAsync: revoke (Off* → false) → office-grant (allow-list → read+create).
await InvokeRevokeAsync(fix);
await InvokeOfficeSeedAsync(fix);
// non-Admin: allow-list 16 key mở READ + CREATE (self-service Văn phòng số).
foreach (var key in AllowListKeys)
{
var row = await GetPermAsync(db, nonAdminId, key);
row.Should().NotBeNull($"{key} phải có row sau office-grant");
row!.CanRead.Should().BeTrue($"{key} mở Read cho user thường (golive Văn phòng số)");
row.CanCreate.Should().BeTrue($"{key} mở Create cho user thường (self-service tạo phiếu)");
}
// ⭐ LOAD-BEARING: 3 excluded key VẪN ẩn (revoke che, office-grant KHÔNG nâng).
foreach (var key in ExcludedOfficeKeys)
(await CanReadAsync(db, nonAdminId, key)).Should()
.BeFalse($"{key} KHÔNG public — chỉ Admin (admin-manage / báo cáo / chấm công)");
}
[Fact]
public async Task Chain_NonAdmin_AllowList_UpdateAndDelete_StayFalse()
{
// Grant chỉ mở read+create — Update/Delete (sửa/xóa phòng, duyệt cấp cao) GIỮ false.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (_, nonAdminId) = await SeedRolesAsync(fix);
await SeedMenuItemsAsync(db);
foreach (var key in AllowListKeys)
AddPerm(db, nonAdminId, key, canRead: true);
await db.SaveChangesAsync(CancellationToken.None);
await InvokeRevokeAsync(fix);
await InvokeOfficeSeedAsync(fix);
foreach (var key in AllowListKeys)
{
var row = await GetPermAsync(db, nonAdminId, key);
row!.CanUpdate.Should().BeFalse($"{key} KHÔNG mở Update cho non-Admin (write quản lý khóa ở controller)");
row.CanDelete.Should().BeFalse($"{key} KHÔNG mở Delete cho non-Admin");
}
}
[Fact]
public async Task Chain_NonAdmin_OfficeGrant_DoesNotLeakIntoHrmOrPersonal()
{
// Office grant CHỈ đụng allow-list Off* — KHÔNG vô tình mở HRM (Dashboard) hay Personal.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (_, nonAdminId) = await SeedRolesAsync(fix);
await SeedMenuItemsAsync(db);
// Pre-grant cả HRM-dashboard + Personal (để revoke thu hồi) — office-grant không được nâng lại.
foreach (var key in AllowListKeys.Concat(OtherModuleHiddenKeys))
AddPerm(db, nonAdminId, key, canRead: true);
await db.SaveChangesAsync(CancellationToken.None);
await InvokeRevokeAsync(fix);
await InvokeOfficeSeedAsync(fix);
foreach (var key in OtherModuleHiddenKeys)
(await CanReadAsync(db, nonAdminId, key)).Should()
.BeFalse($"{key} GIỮ ẩn — Office grant KHÔNG mở HRM/Personal");
}
[Fact]
public async Task Chain_Admin_NotRevoked_KeepsAllOfficeReadAfterChain()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (adminId, nonAdminId) = await SeedRolesAsync(fix);
await SeedMenuItemsAsync(db);
// Admin có read trên cả allow-list + 3 excluded → KHÔNG bị revoke đụng.
foreach (var key in AllowListKeys.Concat(ExcludedOfficeKeys))
{
AddPerm(db, adminId, key, canRead: true);
AddPerm(db, nonAdminId, key, canRead: true);
}
await db.SaveChangesAsync(CancellationToken.None);
await InvokeRevokeAsync(fix);
await InvokeOfficeSeedAsync(fix);
// Admin GIỮ nguyên CanRead=true trên mọi Off* (kể cả 3 excluded) — revoke loại trừ Admin.
foreach (var key in AllowListKeys.Concat(ExcludedOfficeKeys))
(await CanReadAsync(db, adminId, key)).Should().BeTrue($"Admin KHÔNG bị revoke trên {key}");
}
// ============================================================
// SeedAllRolesOfficeModulePermissionsAsync — isolated behavior
// ============================================================
[Fact]
public async Task OfficeSeed_CreatesMissingRow_ReadCreateTrue_UpdateDeleteFalse()
{
// Row chưa có (DB mới) → tạo CanRead=true + CanCreate=true, Update/Delete=false (line 2312).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (_, nonAdminId) = await SeedRolesAsync(fix);
await SeedMenuItemsAsync(db);
// KHÔNG seed Permission row nào.
await InvokeOfficeSeedAsync(fix);
var off = await GetPermAsync(db, nonAdminId, MenuKeys.Off);
off.Should().NotBeNull("office-seed tạo row mới khi chưa tồn tại");
off!.CanRead.Should().BeTrue();
off.CanCreate.Should().BeTrue();
off.CanUpdate.Should().BeFalse();
off.CanDelete.Should().BeFalse();
// Excluded key KHÔNG được office-seed tạo row (chỉ allow-list).
var manage = await GetPermAsync(db, nonAdminId, MenuKeys.OffPhongHopManage);
manage.Should().BeNull("office-seed KHÔNG tạo row cho Off_PhongHop_Manage (ngoài allow-list)");
}
[Fact]
public async Task OfficeSeed_UpgradesExistingFalseRow_PreservesAdminRaisedUpdateDelete()
{
// Upgrade-only path (line 2302-2308): row CanRead=false + CanCreate=false (revoke vừa set)
// → nâng read+create=true. NHƯNG nếu admin đã chỉnh Update/Delete=true → KHÔNG hạ
// (office-grant chỉ đụng Read/Create). Lock invariant "upgrade-only, không phá quyền admin".
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (_, nonAdminId) = await SeedRolesAsync(fix);
await SeedMenuItemsAsync(db);
// Row tiền tồn: Read/Create=false (như sau revoke) nhưng Update/Delete=true (admin nâng tay).
db.Permissions.Add(new Permission
{
RoleId = nonAdminId,
MenuKey = MenuKeys.OffDeXuatCreate,
CanRead = false,
CanCreate = false,
CanUpdate = true,
CanDelete = true,
});
await db.SaveChangesAsync(CancellationToken.None);
await InvokeOfficeSeedAsync(fix);
var row = await GetPermAsync(db, nonAdminId, MenuKeys.OffDeXuatCreate);
row!.CanRead.Should().BeTrue("upgrade nâng Read=true");
row.CanCreate.Should().BeTrue("upgrade nâng Create=true");
row.CanUpdate.Should().BeTrue("office-grant KHÔNG hạ Update đã được admin nâng");
row.CanDelete.Should().BeTrue("office-grant KHÔNG hạ Delete đã được admin nâng");
}
}