[CLAUDE] App+Api+FE-User+FE-Admin+Tests: W2 KHKK — CRUD phieu nhap + can cu b.8-9 + FE 3 page x2 app + 12 fix reviewer
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m56s

- BE mega-file ContractSigningPlanFeatures (Create 4-rao + auto-Lines snapshot per-winner + MaKeHoach @CREATE + UpdateDraft null-safe + Get/List/inbox-precompute-V2/deleted + Dossier + Attachments + Delete allow-list M6 + picker) + Controller 15 action policy per-action 15/15 (KeHoachKyKet.*)
- FE pages/khkk 3 page + types x2 app SHA-pair + 4-place (6 leaf Khkk_* het coming-soon; WfView: user=matrix?type=10 [whitelist +10] / admin=Designer deep-link); root KeHoachKyKet -> /khkk/list
- Reviewer adversarial: 12 finding (3 CRITICAL dut-2-bo FE-BE: route picker + peId body + dossier planId; 2 MAJOR security: detail+download 0 rao -> EnsureCanViewAsync mirror PE S89 nhap-rieng-tu; PUT-vs-POST de-ban-sao; peTenGoiThau; winnerSupplierNames; Content-Disposition RFC6266; LEFT-join Projects) -> 11 FIXED + 1 GIU (F-10 ke thua khuon PE, bit dong bo sau)
- tests: +8 (574 total 0 fail) — authz reflection dong + Create/Delete/guard/khong-dot-ma-khi-409 + fixture-co-interceptor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-29 21:19:41 +07:00
parent 914c5a40f2
commit 6cbc6ad937
20 changed files with 5020 additions and 23 deletions

View File

@ -1,5 +1,6 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Routing; // HttpMethodAttribute — đọc verb thật của action (W2 KHKK)
using SolutionErp.Api.Controllers;
using SolutionErp.Domain.Identity; // MenuKeys — chốt policy name có thật (F-1b S155)
@ -321,4 +322,121 @@ public class AuthorizePolicyRegressionTests
"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).");
}
// ===================================================================
// [W2 KHKK — S161 2026-07-29] ContractSigningPlansController (GĐ2 "Kế hoạch ký kết HĐ").
//
// Shape KHÁC 3 controller ở trên — đừng ép về cùng một kiểu:
// class-level CÓ Policy `KeHoachKyKet.Read` (mọi endpoint tối thiểu Read) + per-action
// override GHI ĐỦ 15/15 action, KỂ CẢ action chỉ-đọc (viết lại Read cho tường minh).
// Lý do ghi thành lời ở `ContractSigningPlansController.cs:12-19`.
//
// Test này quét TOÀN BỘ action (không liệt tên tay) vì rủi ro thật của module mới là
// action thứ 16 thêm sau này QUÊN attribute — liệt tay thì action mới không bao giờ bị soi.
// 3 lớp răng, mỗi lớp bắt một hỏng khác nhau:
// (a) THIẾU attribute → endpoint tụt xuống class-level Read ⇒ ai đọc được cũng GHI được;
// (b) SAI tên policy → `Program.cs` chỉ sinh policy cho key ∈ `MenuKeys.All`; tên lạ
// = 500 lúc chạy (KHÔNG phải 403) — attribute vẫn "trông đúng";
// (c) verb ghi mà policy Read → 403 không bao giờ xảy ra: user chỉ-xem GHI được vào phiếu.
// ===================================================================
/// Bộ action của controller = method public khai TRỰC TIẾP trên class + có `[HttpXxx]`.
/// (Lọc theo HttpMethodAttribute để bỏ property/ctor/record lồng bên trong.)
private static (string Name, string Verb, AuthorizeAttribute? Authz)[] GetActionsWithVerb(Type controllerType)
=> controllerType
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Select(m => new
{
m.Name,
Http = m.GetCustomAttributes<HttpMethodAttribute>(inherit: false).FirstOrDefault(),
Authz = m.GetCustomAttributes<AuthorizeAttribute>(inherit: false).FirstOrDefault(),
})
.Where(x => x.Http is not null)
.Select(x => (x.Name, x.Http!.HttpMethods.First().ToUpperInvariant(), x.Authz))
.ToArray();
[Fact]
public void ContractSigningPlansController_EveryWriteEndpoint_HasAuthorizePolicy()
{
var type = typeof(ContractSigningPlansController);
// ---- (0) Class-level: 2 tầng — tầng 1 CÓ Policy Read (cố ý, khác ApprovalWorkflowsV2).
var classAttr = GetClassLevelAuthorize(type);
classAttr.Should().NotBeNull("controller phải có [Authorize] class-level chặn anonymous");
classAttr!.Policy.Should().Be("KeHoachKyKet.Read",
"tầng 1 của authz 2 tầng: MỌI endpoint KHKK tối thiểu phải có quyền đọc menu này. " +
"Đây KHÔNG phải bẫy gotcha #44 (silent 403) vì module có menu key riêng — user được " +
"cấp KeHoachKyKet mới thấy màn hình này, không phải mọi Drafter như ApprovalWorkflowsV2.");
var actions = GetActionsWithVerb(type);
// ---- Neo chống VACUOUS: foreach trên mảng rỗng luôn xanh. 15 action đo từ đĩa
// 2026-07-29 (5 GET đọc + POST/PUT/DELETE phiếu + 3 dossier-item + 4 attachment).
actions.Should().HaveCountGreaterThanOrEqualTo(15,
"quét được {0} action — dưới 15 nghĩa là phép quét hỏng (đổi BindingFlags/attribute) " +
"chứ không phải controller teo lại; lúc đó mọi assert dưới đây thành vô nghĩa",
actions.Length);
// Verb GHI = mọi thứ không phải GET/HEAD. Không hardcode danh sách endpoint.
var writeVerbs = new[] { "POST", "PUT", "PATCH", "DELETE" };
foreach (var (name, verb, authz) in actions)
{
// ---- (a) MỌI action phải có attribute riêng (kể cả GET — luật của module này).
authz.Should().NotBeNull(
"action {0} ({1}) thiếu [Authorize(Policy = ...)] — nó sẽ tụt xuống class-level " +
"`KeHoachKyKet.Read`, nghĩa là ai ĐỌC được cũng GHI được (gotcha #82)", name, verb);
authz!.Policy.Should().NotBeNullOrEmpty(
"action {0} có [Authorize] TRẦN — `[Authorize]` không Policy = mọi user đăng nhập " +
"lọt qua tầng 2 (bài F-1b S155)", name);
var parts = authz.Policy!.Split('.');
parts.Should().HaveCount(2,
"policy \"{0}\" của {1} phải đúng dạng {{menuKey}}.{{action}}", authz.Policy, name);
// ---- (b) Tên policy phải là tên CÓ THẬT trong bộ sinh policy của Program.cs.
parts[0].Should().Be(MenuKeys.KeHoachKyKet,
"action {0} phải gate bằng menu key của CHÍNH module này — Δ W1 chốt prefix " +
"`KeHoachKyKet` (spec cũ ghi `ContractSigningPlans`, đã bỏ); gate nhầm key module " +
"khác = cấp quyền chéo", name);
MenuKeys.All.Should().Contain(parts[0],
"policy \"{0}\" của {1} trỏ menu key KHÔNG có trong MenuKeys.All ⇒ Program.cs không " +
"sinh policy đó ⇒ 500 lúc chạy chứ không phải 403", authz.Policy, name);
MenuKeys.Actions.Should().Contain(parts[1],
"policy \"{0}\" của {1} dùng action không có trong MenuKeys.Actions", authz.Policy, name);
// ---- (c) Verb GHI KHÔNG được gate bằng Read (403 sẽ không bao giờ xảy ra).
if (writeVerbs.Contains(verb))
{
parts[1].Should().NotBe("Read",
"{0} là {1} (ghi) mà chỉ gate `Read` ⇒ user chỉ-xem VẪN ghi được vào phiếu", name, verb);
parts[1].Should().BeOneOf(["Create", "Update", "Delete"],
"verb ghi {0} phải gate Create/Update/Delete", name);
}
else
{
parts[1].Should().Be("Read",
"{0} là {1} (đọc) — gate mạnh hơn Read sẽ khóa nhầm người chỉ có quyền xem " +
"(403 silent, gotcha #44)", name, verb);
}
}
// ---- (d) Chốt 3 endpoint mấu chốt bằng TÊN — nếu ai đổi POST phiếu sang `Update`
// thì luật (c) ở trên vẫn xanh (Update cũng là verb ghi hợp lệ).
var byName = actions.ToDictionary(a => a.Name, a => a.Authz?.Policy);
byName[nameof(ContractSigningPlansController.Create)].Should().Be("KeHoachKyKet.Create",
"POST tạo phiếu = quyền Create");
byName[nameof(ContractSigningPlansController.UpdateDraft)].Should().Be("KeHoachKyKet.Update",
"PUT sửa nháp = quyền Update");
byName[nameof(ContractSigningPlansController.Delete)].Should().Be("KeHoachKyKet.Delete",
"DELETE phiếu = quyền Delete RIÊNG (không gộp Update) — xoá là hành vi khác sửa");
// Ghi vào phiếu (căn cứ b.8-9 / đính kèm) = Update chứ KHÔNG Create: "tạo" ở module
// này nghĩa là tạo PHIẾU. Chốt lại để không ai "sửa cho nhất quán" nhầm hướng.
byName[nameof(ContractSigningPlansController.CreateDossierItem)].Should().Be("KeHoachKyKet.Update",
"thêm căn cứ = ghi vào phiếu đã có ⇒ Update; gate Create sẽ chặn oan người được giao " +
"quyền sửa phiếu nhưng không được lập phiếu mới");
byName[nameof(ContractSigningPlansController.UploadAttachment)].Should().Be("KeHoachKyKet.Update",
"upload đính kèm = ghi vào phiếu đã có ⇒ Update");
}
}