Compare commits

...

3 Commits

Author SHA1 Message Date
c808b9ad81 [CLAUDE] Auth: gate ReportsController tai-chinh HD behind Reports.Read policy (reviewer S118)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m34s
Lo hong (reviewer S118 phat hien khi phan quyen PRO tra.bui): ReportsController chi co
[Authorize] tran (khong policy) -> MOI user dang nhap goi duoc GET /reports/dashboard (tong gia
tri HD dang chay, top NCC/du an theo gia tri, xu huong 12 thang) + GET /reports/contracts/export
(xuat toan bo HD ra Excel). Nguoc quyet dinh S92 "HD chi Admin thay" (menu an nhung API van mo,
go URL/API truc tiep lay duoc).

Fix (owner chot: chi Admin qua Reports.Read policy -- linh hoat mo role khac qua ma tran sau):
- Dashboard() + ExportContracts() -> them [Authorize(Policy = "Reports.Read")] per-method. Hien
  chi Admin co Reports.Read -> non-admin 403. Admin bypass moi policy nhu cu.
- MyDashboard() GIU MO (chi [Authorize] class-level) -- scope theo currentUser, moi user chi thay
  so cua CHINH MINH (draft/pending/SLA/gia tri draft cua minh), khong lo toan cong ty.

Test: +4 reflection authz regression (AuthorizePolicyRegressionTests, mirror gotcha #44 S45):
dashboard/export co Reports.Read; my-dashboard KHONG co action-policy (guard chong ai khoa nham
dashboard ca nhan). 505 -> 509. Gotcha #82 (menu-flag grant != quyen API that).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:55:18 +07:00
1491d29b10 wal: flush 20260714T1549 2026-07-14 15:49:59 +07:00
b7f7b56860 wal: flush 20260714T1531 2026-07-14 15:31:47 +07:00
5 changed files with 88 additions and 2 deletions

View File

@ -38,7 +38,7 @@ cicd-spawn đôi khi trả memory-curation slice ("Compressing S112/S111") thay
- **Stage 0 RAG infra:** `Get-Service Qdrant` Running + `http://localhost:6333/healthz`. Collection `proj_solution_erp`.
- **Stage 1 Push+filter:** `git log -1 --format='%H %s'` + `git log origin/main..HEAD` empty + diff vs paths-ignore (docs-only SKIPPED-DOCS return).
- **Stage 2 Gitea poll** (max 10 iter × 60s): API `.../actions/tasks?limit=5` (NOT `/runs` 404). Match `head_sha` (NOT run_number cancelled runs shift numbering). `updated_at` stale ~2min (gotcha #46) cross-check VPS mtime. Foreground-sleep BLOCKED Windows Monitor-style busy-wait-curl-spin ~30s/iter.
- **Stage 3 Test gate:** baseline **501 PASS** (45 Domain + 456 Infra; S117 #387 `316a82f` 495+6 PE approved-price-range tests [NEW PeApprovedPriceFinalizeTests + PeFinalizeProjectionTests mod]; Infra 450456, Domain frozen 45; prior 495 [S116 #386 `88368fd` 486+9 submit-guard/finalize/budget], 486, 477, 458, 440, 436, 434, 421). CI runs both proj BEFORE build/deploy status=success test gate passed (`tasks` terminal=`status:success`, `conclusion` NOT populated trust success NOT log-numeric). Local grep undercounts (Theory/InlineData). Phase 9 UAT skip OK.
- **Stage 3 Test gate:** baseline **505 PASS** (45 Domain + 460 Infra; S118 #388 `c2135ba` 501+4 `ProcurementMasterAccessSeedTests` scope-isolation; Infra 456460, Domain frozen 45; prior 501 [S117 #387 `316a82f` PE approved-price-range], 495, 486, 477, 458, 440, 436, 434, 421). CI runs both proj BEFORE build/deploy status=success test gate passed (`tasks` terminal=`status:success`, `conclusion` NOT populated trust success NOT log-numeric). Local grep undercounts (Theory/InlineData). Phase 9 UAT skip OK.
- **Stage 4 Post-deploy** (if SUCCESS): auth login bearer (admin + nv.test gotcha #44; token=`accessToken` route `/api/auth/login`) 3-5 endpoint smoke 2XX (incl new NEW-endpoint bodyless-POST returns **411 IIS Length-Required pre-auth NOT 404**; re-probe WITH body 401 confirms wired) FE bundle hash 2 app (per #69 = real-size-vs-fake not hash-delta) SignalR negotiate (gotcha #25) EF mig prod==repo.
- **Stage 4.6 (S29 CRITICAL):** sqlcmd seed sample verify post-deploy (NOT chỉ schema). Seed=runtime-row-insert (no history/tables advance) verify via DB-query (e.g. Permissions count), NOT bundle-frozen-alone.
- Discovery: ASP.NET 10 record enum needs numeric input unless `JsonStringEnumConverter` (SOL has NO converter FE sends numeric). sqlcmd ssh Windows-auth `\\\\SQLEXPRESS` 4-backslash. INFRASTRUCTURE seed MUST run (NOT inside `if(!demoSeedDisabled)`); DEMO seed gated gotcha #51.
@ -60,6 +60,7 @@ cicd-spawn đôi khi trả memory-curation slice ("Compressing S112/S111") thay
- **Mig latest repo: Mig 66 `AddPeApprovedPriceRange`** (`20260714035636`, S117 `316a82f`; single AddColumn `PurchaseEvaluations.ApprovedPriceMaxAmount decimal(18,2) nullable` cột giá-chốt MAX của "duyệt theo khoảng MinMax" src ProMinMax; VERIFIED-APPLIED-PROD `COL_LENGTH=9` + history-top `20260714035636_AddPeApprovedPriceRange` + sys.tables **89 giữ** [AddColumn no-new-table]). Prev Mig 65 `BackfillPeQuoteIsSelectedAndWinnerInvariant` / 64 `AddSupplierPublishState` / 62 `ExpandSupplierFields` (see docs/CLAUDE.md migration table). Path `src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/`. Prod check `sqlcmd __EFMigrationsHistory ORDER BY MigrationId DESC`. **`sys.tables`(is_ms_shipped=0)=89** AddColumn/data-only don't change count; commit touching no new table 89 correct, don't FAIL. enum-additive = int NO-mig (history NOT-advance + empty `git diff -- '*Migrations*'`).
- **Bundle hash live:** current ship-baseline = S117 #387 (admin js `cFI7ih4a`/css `CfunQHbd` · user js `MxgooVZw`/css `DGqqHQXS`; size 1,656,696/1,563,044b; LM 04:18:21/04:19:16 GMT = 11:18/11:19+07 07-14) JS-rotate ×2/CSS-frozen from S116 (Dd55jBpj/DVDr1UQ4). See S117 recent-run entry for markers. idrun_number (head_sha-keyed). PER #69 TWO-MODE hash is SNAPSHOT-per-run NOT stable-baseline (rotate on real-src OR benign-0-src; freeze on 0-src) ALWAYS re-snapshot LIVE post-success; ship-proof = LM-in-window + real-size (vs 919b SPA-trap) + marker-grep, NOT hash-delta. js-rotate/css-frozen = FE-logic-no-style fingerprint.
- **Bearer:** admin `admin@solutions.com.vn/Admin@123456` (full) · UAT `nv.test@solutions.com.vn/TestUser@123456` (Drafter CCM, gotcha #44 check).
- **API Serilog log (S118 correction):** real sink = `C:\inetpub\solution-erp\api\logs\solution-erp-YYYYMMDD.log` (lowercase `logs`, rolling daily). skill-runbook path `…\Logs\log-YYYYMMDD.txt` STALE empty grep. MinimumLevel Default=Information (DbInitializer `LogInformation` seed/revoke lines DO persist) Microsoft/System overridden Warning (no "Now listening" INFO). **Startup seed-race audit pattern (durability deploys):** grep this log for revoke-line THEN seeder-line same-second "N added + M upgraded" proves seeder actively flipped rows revoke knocked down (0-upgraded = seeder no-op = FAIL flag).
## 🔑 Critical config (flag commit nếu tái xuất)
Node CI `20.x` (`feedback_node_cicd`) · MediatR `12.4.1` (gotcha #1, flag `Version="14`) · Swashbuckle `6.9.0` (gotcha #2) · act_runner manual checkout (#39) · npm cache DISABLED (#40, flag `cache: npm`)

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: decide WHAT to test (test plan) → em main + reviewer chốt priority
## 📊 Baseline: 501 tests = 501 PASS (45 Domain + 456 Infra) as of S117 (was 495@S116; +5 PE ProMinMax [own] +1 endsBeforeCeo-pointer-aware [em-main-solo, PeFinalizeProjectionTests], filtered-run 15/15)
## 📊 Baseline: 505 tests = 505 PASS (45 Domain + 460 Infra) as of S118 (was 501@S117; +4 Infra Procurement master-access seeder isolation regression, filtered-run 4/4)
- Count history + verbose per-session test-design notes (S54→D2): [archive/baseline-history.md](archive/baseline-history.md) (L2, on-demand).
- Latest (D2): +6 PE Mig 58 multi-winner — `CreateContractFromEvaluationMultiWinnerTests.cs` (5, 1→N HĐ per IsWinner, GiaTri per-winner=quote-total / single=legacy-budget-sum) + `PeListWinnerNamesProjectionTests.cs` (1, EF collection-projection translation-smoke). No prod bug.
> 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.
@ -56,6 +56,7 @@ Test theo CODE (single source truth), document mismatch header comment + report.
## 📅 Recent activity (one-line FIFO — verbose in [archive/baseline-history.md](archive/baseline-history.md))
- **S118 (2026-07-14) Procurement master-access seeder SCOPE-ISOLATION regression (test-after SECURITY — anh Kiệt FDC) — 501→505 PASS (+4 Infra), no-red, NO prod bug:** new `ProcurementMasterAccessSeedTests.cs` (4 [Fact]) guard seeder MỚI `SeedProcurementMasterAccessAsync` (private static, DbInitializer) — NGOẠI-LỆ role Procurement mở lại Danh mục SAU S92 revoke ("chỉ Admin thấy"). Chain thật: Revoke→SeedProcurement. **T1 upgrade-path** Procurement sau revoke→seed đúng ma trận (Suppliers R+C+U D=false + 8 read-only chỉ R). **T2 ⭐SCOPE-ISOLATION (core)** DeptManager đứng RIÊNG (KHÔNG kèm Procurement): pre-grant→revoke→seed → 9 key VẪN all-false (row tồn tại giữ vết) = grant KHÔNG rò role khác; +CONTRAST assert Procurement THẬT được nâng (chống vacuous-pass). **T3 insert-path** DB mới no-revoke → INSERT 9 row, Delete=false + count==9. **T4 idempotent** seed ×2 → bất biến + count==9 no-dup + role khác vẫn ẩn. Reflection harness MIRROR `AdminOnlyModulesRevokeTests` (NonPublic|Static, sig (ApplicationDbContext,RoleManager<Role>,ILogger), FK seed MenuItem→Role→Permission). No user cần (perm role-level). **Reflection KHÔNG vướng — KHÔNG cần đổi visibility.** KHÔNG touch prod.
- **S117 (2026-07-14) PE "duyệt theo KHOẢNG" ProMinMax range-price TEST-BEFORE-MERGE (financial-critical) — 495→500 PASS (+5 Infra, file 10→15), no-red, NO prod bug:** feature anh Kiệt: PRO có cả Min&Max → người duyệt cấp cuối chọn CẢ HAI (source="ProMinMax") → `ApprovedPriceAmount=Min`, `ApprovedPriceMaxAmount=Max`. Bất-biến `Max!=null ⟺ Source=="ProMinMax"`. Prod đã land: `ApplyApprovedPriceOnFinalize(...,decimal? maxAmount=null)` +`ValidApprovedPriceSources`+"ProMinMax"; `TransitionAsync`/`ApproveV2Async` +`approvedPriceMaxAmount` (SAU source). 5 new [Fact] qua reachable terminal path (mirror case-1 SeedSingleApproverWorkflow, KHÔNG gọi private): (a) happy Min100/Max130 set cả 2 · (a-boundary) **min==max OK** lock guard `>` vs `>=` (revert→RED) · (b) thiếu Max→Conflict "*phải có cả giá Min và giá Max*" not-finalized · (c) min>max→Conflict "*không được lớn hơn giá Max*" · (d) nguồn ĐƠN+Max→Conflict "*kèm giá Max*" wire-bug-guard. +regression assert `Max==null` vào Theory 3b (4 nguồn đơn). **⚠️ Gotcha (self-inflicted-avoided):** prod method 5-param nay → `InvokeApply` reflection helper phải pass 5 args (`{...,maxAmount}`); optional param KHÔNG auto-bind qua `MethodInfo.Invoke` → nếu giữ 4 args = `TargetParameterCountException` phá unit-test 4/4b/4c. Đã fix helper. KHÔNG touch prod.
- **S116 (2026-07-13) PE báo giá SỐ ÂM regression-lock (test-before cho guard R2-revised — critical-algo/financial) — 486→495 PASS (+9 Infra), no-red, NO prod bug:** feature anh Kiệt cho ThanhTien ÂM (phát-sinh-giảm/hoàn tiền NCC = giá THẬT). BE ĐÃ đổi submit-guard `:202-208` `Sum()<=0``Count==0`(chưa chọn)/`else All(x==0)`(chưa có giá). 4 nhóm test khớp hành-vi MỚI: **T1** `PeWorkItemBudgetTests` (+1) quote ÂM 20tr KHÔNG chạm PeWorkItemBudget record (FullAmount ngân sách bất biến) NHƯNG CHẢY vào CurrentProposalTotal=20tr (chứng tách-biệt cột đề-xuất vs ngân-sách, "0 code" kiến-trúc). **T2** `PeMultiWinnerTests` (+2) winnerQuoteTotal=SUM CÓ DẤU khác-winner(+50,20=30) + cùng-winner-2-dòng(80,30=50). **T3** `PeSubmitGuardAndBypassTests` (+4, +2 helper `SeedWinnerWithQuotesAsync` params + `BuildV2PeReadyToSubmitWithQuotesAsync`): all-zero[0,0]→BLOCKED "chưa có giá"; pure-neg[500k]→ChoDuyet; mixed net>0→ChoDuyet; 🔒 **mixed net==0 từng-dòng≠0 [+500k,500k]→ChoDuyet** = ranh giới phân-biệt All(==0) vs Sum()<=0 (revert→RED). **T4** `PeCcmThresholdFinalizeTests` (+2, +helper 2-signed-quote): NET threshold — [+1.2tỷ,0.3tỷ]=0.9tỷ<ngưỡngCCM finalize no-throw + CONTRAST [+1.2tỷ] alone=1.2tỷ≥ngưỡng→Conflict (dòng âm LOAD-BEARING, R-CEO giữ NET). KHÔNG touch prod.
- **S113 (2026-07-12) Supplier import v2 dedup + publish-guard TEST-AFTER (critical-algo + security) 458477 PASS (+19 Infra), no-red, 🔴 1 BUG-FOUND (REPORTED em-main, NOT fixed):** 2 new files `Services/SupplierImportV2DedupTests.cs` (13) + `Application/SupplierPublishAndListTests.cs` (6). Existing S112 file UNTOUCHED (re-baked ExpectedHeaderTokens=30 \n-token real tokens khớp Case9 tự flip LayoutValid=true). **Dedup precedence** existing-MSTexisting-Codebatch-MSTbatch-CodeNew: strip-ws-normalize MST-match · 2-blank-MST=2-insert (blank không index) · same-MST-batch=1-insert+fill-null-skip · **Code-backstop CI** (S112: dedup SERVICE OrdinalIgnoreCase, SQLite BINARY KHÔNG bắt "backstop"≈"BACKSTOP" xanh=chứng không dựa DB) · **MST→A/Code→B = MST-precedence WINS, no silent-clobber** (FillNulls skip Code A.Code giữ, B untouched). **Publish** `PublishSupplierCommand` (KHÔNG UpdateSupplier #73): gate D2 Publish+blank-CodeConflict "Cần NCC" · unique-exactConflict "đã tồn tại" (⚠ `x.Code==newCode` DB-collation, SQLite bắt exact-case only prod SQL CI bắt CI-dup; asymmetry vs import, observation không bug) · unpublish always. ListSuppliers Published t/f/null · GetSupplier DTO.IsPublic. **🔴 BUG:** `NewSupplier` `Code=r.Code!.Trim()` NRE khi row.Code==NULL (path R4 nháp: parser sinh null ô rỗng + preview classify New no-error confirm NRE 500 CẢ batch dbCount=0). Empty ""=OK (T8/T14). Characterization test CHỐT NRE fix null-safe thì flip NotThrow+draft.

View File

@ -1348,6 +1348,14 @@ for h in resp.points: # ← .points không phải iterable trực tiếp
**Bài học:** derived-invariant phải re-establish **MỌI mutation đổi tập-nguồn — kể cả DELETE + cascade-delete**, không chỉ write/select. **Enumeration writer trong spec KHÔNG đáng tin** grep độc-lập MỌI `.Remove(` chạm entity chứa field-nguồn (S87/S88 cardinality class mở rộng lên tầng mutation-path: writer-miss read-site-miss). Chỉ **verify adversarial-refute** bắt (invest/review/implement đều sót cùng theo enumeration). Liên-quan #72/#73 (cardinality) + `feedback_cardinality_change_grep_consumers`.
### 82. Menu-flag permission grant ≠ quyền API thật — controller gate GET bằng `[Authorize]` trần (Session 118)
**Triệu chứng:** S118 phân quyền role Procurement grant menu `Suppliers` (R+C+U) tưởng "menu hiện = làm được mọi thứ". THỰC TẾ 2 chiều lệch: (1) `PUT/DELETE /suppliers` gate `[Authorize(Roles="Admin,CatalogManager")]` PRO vẫn **403 khi Sửa/Xóa NCC** CanUpdate=1 (chỉ Publish/Import theo policy `Suppliers.Update` ăn); nút Sửa hiện nhưng bấm lỗi. (2) Ngược lại `ReportsController` chỉ `[Authorize]` **trần (KHÔNG policy)** menu bị S92 ẩn nhưng **MỌI user đăng nhập** vẫn gọi `GET /reports/dashboard` lấy tổng giá trị + top NCC/dự án theo giá trị ( URL/API trực tiếp, bỏ qua menu).
**Cơ chế:** Menu-tree (`GetMyMenuTree`) filter theo Permission `CanRead` CHỈ đổi HIỂN THỊ menu FE. Hiệu-lực API THẬT chỉ endpoint mang `[Authorize(Policy="X.Y")]` (map qua `MenuPermissionHandler`). Nhiều controller master gate GET bằng `[Authorize]` trần (mở mọi authed user, chủ ý S59) + gate write bằng `[Authorize(Roles=...)]` (KHÔNG theo menu-key). Nên grant "R/C/U" phần lớn = FE-button-visibility; delta API thật mong đợi.
**Fix:** (1) Endpoint lộ data nhạy gate `[Authorize(Policy="Reports.Read")]` per-method (S118 `dashboard` + `contracts/export`; `my-dashboard` giữ mở scope theo `currentUser`). (2) Quy tắc: TRƯỚC khi tin hiệu-lực 1 grant **grep authz-attribute của CHÍNH controller đích** (`[Authorize(Policy=...)]` vs `[Authorize(Roles=...)]` vs `[Authorize]` trần). **Bài học:** grant permission = 2 tầng ĐỘC-LẬP: menu-display (Permission table) API-authz (controller attribute) verify CẢ HAI. reviewer (adversarial) bắt được grep authz thật; em-main-solo tin "menu-flag=quyền" thì sót. Liên-quan #44 (silent 403).
---
## Checklist debug bug mới

View File

@ -14,15 +14,24 @@ namespace SolutionErp.Api.Controllers;
[Authorize]
public class ReportsController(IMediator mediator) : ControllerBase
{
// [S118 — reviewer catch] Báo cáo TOÀN CÔNG TY (tổng giá trị HĐ, top NCC/dự án theo giá trị,
// xu hướng 12 tháng) = nhạy cảm → gate Reports.Read (owner chốt: chỉ Admin, khớp S92 "HĐ chỉ
// Admin thấy"). Hiện chỉ Admin có Reports.Read → non-admin 403. Mở cho role khác = cấp Reports.Read
// qua ma trận (không sửa code). Trước S118: chỉ [Authorize] trần → mọi user đăng nhập gọi được.
[HttpGet("dashboard")]
[Authorize(Policy = "Reports.Read")]
public async Task<ActionResult<DashboardStatsDto>> Dashboard(CancellationToken ct)
=> Ok(await mediator.Send(new GetDashboardStatsQuery(), ct));
// GIỮ MỞ (chỉ [Authorize] class-level): my-dashboard scope theo currentUser.UserId — mỗi user
// chỉ thấy số của CHÍNH MÌNH (draft/pending/SLA/giá trị draft của mình). Không lộ toàn công ty.
[HttpGet("my-dashboard")]
public async Task<ActionResult<MyDashboardDto>> MyDashboard(CancellationToken ct)
=> Ok(await mediator.Send(new GetMyDashboardQuery(), ct));
// [S118 — reviewer catch] Xuất TOÀN BỘ HĐ ra Excel = nhạy cảm → gate Reports.Read (chỉ Admin, S92).
[HttpGet("contracts/export")]
[Authorize(Policy = "Reports.Read")]
public async Task<IActionResult> ExportContracts(
[FromQuery] ContractPhase? phase,
[FromQuery] Guid? supplierId,

View File

@ -200,4 +200,71 @@ public class AuthorizePolicyRegressionTests
attr.Should().NotBeNull("POST satellite CreateWorkHistory phải có action-level policy");
attr!.Policy.Should().Be("Hrm_HoSo.Create");
}
// ===================================================================
// S118 (2026-07-14) — Reviewer catch: ReportsController lỗ hổng lộ tài chính toàn công ty.
//
// Trước S118: cả controller chỉ [Authorize] class-level trần → MỌI user đăng nhập gọi được
// GET /reports/dashboard (tổng giá trị HĐ, top NCC/dự án theo giá trị, xu hướng 12 tháng) +
// GET /reports/contracts/export (xuất TOÀN BỘ HĐ ra Excel) → lộ tài chính HĐ toàn công ty.
//
// Fix (owner chốt, khớp S92 "HĐ chỉ Admin thấy"): 2 endpoint company-wide gate Policy
// "Reports.Read" (hiện chỉ Admin có) — mở role khác = cấp qua ma trận, KHÔNG sửa code.
// my-dashboard CỐ Ý giữ mở (scope theo currentUser — mỗi user chỉ thấy số của chính mình).
//
// Regression coverage: nếu ai gỡ Policy khỏi 2 endpoint company-wide → tái lộ tài chính;
// hoặc thêm nhầm Policy vào my-dashboard → khóa dashboard CÁ NHÂN của role không-Admin.
// Cả 2 hướng đều FAIL test ngay, không cần UAT reproduce.
// ===================================================================
[Fact]
public void ReportsController_ClassLevel_AuthorizeOnly_NoPolicy_NoRoles()
{
// Class-level [Authorize] trần (any-authenticated) — gate company-wide đặt PER-ACTION.
// Nếu hardcode Policy/Roles ở class-level sẽ khóa luôn my-dashboard cá nhân (gotcha #44).
var attr = GetClassLevelAuthorize(typeof(ReportsController));
attr.Should().NotBeNull("ReportsController phải có [Authorize] class-level chặn anonymous");
attr!.Policy.Should().BeNull(
"class-level KHÔNG được hardcode Policy — my-dashboard cần mở cho mọi authenticated. " +
"Gate company-wide đặt action-level (Reports.Read).");
attr.Roles.Should().BeNull("class-level KHÔNG được hardcode Roles");
}
[Fact]
public void ReportsController_Dashboard_GET_RequiresReportsReadPolicy()
{
// GET /reports/dashboard = báo cáo TOÀN CÔNG TY → phải gate Reports.Read (chỉ Admin, S118).
var attr = GetActionAuthorize(typeof(ReportsController), nameof(ReportsController.Dashboard));
attr.Should().NotBeNull("GET dashboard (báo cáo toàn công ty) phải có [Authorize(Policy = ...)]");
attr!.Policy.Should().Be("Reports.Read",
"dashboard company-wide (tổng giá trị HĐ, top NCC/dự án, xu hướng 12 tháng) chỉ Admin — " +
"gate Reports.Read (S118 vá lỗ hổng lộ tài chính). Gỡ Policy = tái lộ toàn công ty.");
}
[Fact]
public void ReportsController_ExportContracts_GET_RequiresReportsReadPolicy()
{
// GET /reports/contracts/export = xuất TOÀN BỘ HĐ ra Excel → gate Reports.Read (chỉ Admin, S118).
var attr = GetActionAuthorize(typeof(ReportsController), nameof(ReportsController.ExportContracts));
attr.Should().NotBeNull("GET contracts/export (xuất toàn bộ HĐ) phải có [Authorize(Policy = ...)]");
attr!.Policy.Should().Be("Reports.Read",
"export toàn bộ HĐ ra Excel = nhạy cảm → gate Reports.Read chỉ Admin (S118).");
}
[Fact]
public void ReportsController_MyDashboard_GET_StaysOpen_NoActionPolicy()
{
// GET /reports/my-dashboard CỐ Ý giữ mở (chỉ inherit class-level [Authorize] any-authenticated)
// — scope theo currentUser.UserId, mỗi user chỉ thấy số của CHÍNH MÌNH, không lộ toàn công ty.
// Nếu ai thêm [Authorize(Policy=...)] action-level → khóa nhầm dashboard CÁ NHÂN của mọi role
// không có Reports.Read (403 silent). Cho phép bare [Authorize] (vô hại) nhưng CHẶN mọi Policy.
var attr = GetActionAuthorize(typeof(ReportsController), nameof(ReportsController.MyDashboard));
(attr?.Policy).Should().BeNull(
"GET my-dashboard KHÔNG được gate Policy — mỗi user chỉ thấy số của chính mình. " +
"Thêm policy sẽ khóa dashboard cá nhân của role không có Reports.Read (S118 giữ mở có chủ đích).");
}
}