[CLAUDE] Contract: K7 SP-002 bridge KHKK->HD hoi-2-khoa + Line.ContractId + grant seeder (gate PASS-WITH-FLAGS 10, F-8 fixed)
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled

BE: POST {id}/create-contract (Contracts.Create AND KeHoachKyKet.Read) + guard 4-ve IsActive/IsUserSelectable
+ Line.ContractId write-site DAU TIEN + list-DTO ContractIds + SeedContractCreateAccessAsync (Drafter/
Procurement/Admin upgrade-only, cau III default cho ratify). FE x2 app SHA-pair 3/3: nut bridge gate 2-khoa
+ dialog khuon-san + cay GD3/GD4 doc TAP contractIds (union pe.contractId legacy). Test +17 (12 bridge T1-T12
+ 5 authz) suite 639/0; ngoai-le CreateContract trong AuthorizePolicyRegressionTests (#85).
Gate F-8 fixed truoc commit: neo so-dong :2516 chet-luc-land -> neo ten ham (3 cho) + 13/13->12/13 theo do
prod + khai chu-dich CanRead + sua tro-nham-ham. No mang theo (0-blocker): MAJOR-race-2-request-dong-thoi
(chong-trung in-memory, phuong an unique/RowVersion = nhip sau) + DraftData-khong-carry + a11y/O(n.m) FE.
UAT-NOI-TRUOC: nut sang sau restart + user RE-LOGIN (menu cache); acceptance seeder log = 0 added + 2 upgraded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-01 13:04:49 +07:00
parent c97f948a7b
commit aaed699228
13 changed files with 2241 additions and 46 deletions

View File

@ -355,6 +355,23 @@ public class AuthorizePolicyRegressionTests
.Select(x => (x.Name, x.Http!.HttpMethods.First().ToUpperInvariant(), x.Authz))
.ToArray();
/// 🔴 [K7 S167] NGOẠI LỆ CÓ TÊN — action tạo ra tài nguyên của MODULE KHÁC phải gate bằng
/// key policy của **thứ được tạo**, không phải key của controller đang đứng (gotcha #85:
/// "gate quyền dùng đúng key policy của endpoint"; `MenuPermissionHandler.cs:40` so khớp
/// CHÍNH XÁC 1 key, không kế-thừa ⇒ gate nhầm key = cấp quyền chéo hoặc khoá oan).
///
/// Cầu GĐ2→GĐ3 `POST {id}/create-contract` SINH RA một `Contract` ⇒ vế ghi = `Contracts.Create`.
/// Vế đọc vẫn còn: class-level `KeHoachKyKet.Read` (`:28`) — ASP.NET Core cộng dồn class +
/// action theo **AND**, nên endpoint này là HỘI 2 KHOÁ, KHÔNG phải nới lỏng.
///
/// Viết dạng BẢN ĐỒ kỳ-vọng-CHÍNH-XÁC (không phải danh sách "bỏ qua"): action trong đây vẫn
/// bị đo, chỉ đổi key kỳ vọng. Gate sai thành `KeHoachKyKet.Create` (hoặc bất cứ key nào khác)
/// vẫn ĐỎ.
private static readonly Dictionary<string, string> KhkkCrossModuleGates = new()
{
[nameof(ContractSigningPlansController.CreateContract)] = "Contracts.Create",
};
[Fact]
public void ContractSigningPlansController_EveryWriteEndpoint_HasAuthorizePolicy()
{
@ -395,10 +412,23 @@ public class AuthorizePolicyRegressionTests
"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);
// Kỳ vọng mặc định = key của CHÍNH module; ngoại lệ CÓ TÊN (K7 cầu → HĐ) đổi kỳ vọng
// sang key của tài nguyên ĐƯỢC TẠO — vẫn là so-khớp TUYỆT ĐỐI, không phải bỏ qua.
var expectedPolicy = KhkkCrossModuleGates.GetValueOrDefault(name);
if (expectedPolicy is not null)
{
authz.Policy.Should().Be(expectedPolicy,
"action {0} tạo ra tài nguyên của module KHÁC ⇒ gate bằng key policy của " +
"endpoint đích (gotcha #85). Gate bằng `KeHoachKyKet.*` sẽ cho người chỉ có " +
"quyền trên kế hoạch ký kết ĐẺ RA hợp đồng — quyền chéo đúng nghĩa", name);
}
else
{
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);
@ -438,5 +468,15 @@ public class AuthorizePolicyRegressionTests
"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");
// ---- (e) [K7 S167] Cầu GĐ2→GĐ3 — chốt bằng TÊN, 2 chiều.
byName[nameof(ContractSigningPlansController.CreateContract)].Should().Be("Contracts.Create",
"cầu KHKK→HĐ sinh ra Contract ⇒ vế GHI phải là khoá của Contract (gotcha #85). " +
"Đây là vế 2 của HỘI-2-KHOÁ; vế 1 = class-level `KeHoachKyKet.Read` đã chốt ở (0)");
// Ngoại lệ phải trỏ action CÓ THẬT: đổi tên/xoá action mà quên dọn bản đồ thì mục đó
// thành cấu-hình-chết (im lặng, không ai đo) — vắng-mặt trông giống ổn.
KhkkCrossModuleGates.Keys.Should().BeSubsetOf(byName.Keys,
"bản đồ ngoại lệ trỏ action không còn tồn tại trên controller");
}
}

View File

@ -0,0 +1,245 @@
using System.Reflection;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Api.Authorization;
using SolutionErp.Api.Controllers;
using SolutionErp.Domain.Identity;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Api;
// [K7 — S167 2026-08-01] AUTHZ 2 CHIỀU cho cầu GĐ2→GĐ3 `POST {id}/create-contract`.
//
// Khác `AuthorizePolicyRegressionTests` (chỉ đọc ATTRIBUTE bằng reflection = "cửa có khoá tên gì"),
// file này chạy **máy quyết định thật** `MenuPermissionHandler` = "khoá đó có mở/không mở cho ai".
// Hai lớp bổ nhau: pin tên khoá mà không đo quyết định thì vẫn có thể sai ở tầng dữ liệu quyền,
// và ngược lại.
//
// 🔴 ĐIỀU KIỆN LÀM PHÉP ĐO CÓ RĂNG: 2 tên policy KHÔNG hardcode — ĐỌC TỪ CHÍNH CONTROLLER
// (class-level + action-level của `CreateContract`). Ai sửa attribute thì test này đo theo cái
// MỚI, và vẫn hỏi đúng câu: "hai vế có phải HỘI không". Hardcode chuỗi ở đây là tự tay tách
// test khỏi thứ nó bảo vệ.
//
// Vì sao "hội 2 khoá" quan trọng (gotcha #85 + #82): ASP.NET Core cộng dồn `[Authorize]` class +
// action theo **AND**. Nếu ai đó gỡ 1 vế:
// - gỡ `Contracts.Create` ⇒ người chỉ được xem/sửa kế hoạch ký kết ĐẺ RA hợp đồng (quyền chéo);
// - gỡ `KeHoachKyKet.Read` ⇒ người ngoài module lấy dòng của phiếu người khác đi tạo hợp đồng.
public class KhkkBridgeAuthzTests
{
/// Nạp 2 vế khoá của endpoint cầu TỪ ĐĨA (không hardcode).
private static (string ClassPolicy, string ActionPolicy) BridgePolicies()
{
var t = typeof(ContractSigningPlansController);
var classPolicy = t.GetCustomAttributes<AuthorizeAttribute>(inherit: false)
.FirstOrDefault()?.Policy;
var actionPolicy = t
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.First(m => m.Name == nameof(ContractSigningPlansController.CreateContract))
.GetCustomAttributes<AuthorizeAttribute>(inherit: false).FirstOrDefault()?.Policy;
classPolicy.Should().NotBeNullOrEmpty("vế 1 (class-level) của hội-2-khoá biến mất");
actionPolicy.Should().NotBeNullOrEmpty("vế 2 (action-level) của hội-2-khoá biến mất");
actionPolicy.Should().NotBe(classPolicy,
"hai vế TRÙNG NHAU thì 'hội 2 khoá' chỉ còn là 1 khoá — cửa tự nới ra mà nhìn vẫn như cũ");
return (classPolicy!, actionPolicy!);
}
/// Chạy ĐÚNG máy production: `MenuPermissionHandler` + `UserManager`/`RoleManager` thật.
/// Trả về quyết định cho MỘT policy "{menuKey}.{action}".
private static async Task<bool> AllowsAsync(IdentityFixture fix, User user, string policy)
{
var parts = policy.Split('.');
parts.Should().HaveCount(2, "policy \"{0}\" không đúng dạng {{menuKey}}.{{action}}", policy);
var handler = new MenuPermissionHandler(
fix.Services.GetRequiredService<TestApplicationDbContext>(),
fix.Services.GetRequiredService<UserManager<User>>(),
fix.Services.GetRequiredService<RoleManager<Role>>());
var principal = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())], "test"));
var req = new MenuPermissionRequirement(parts[0], parts[1]);
var ctx = new AuthorizationHandlerContext([req], principal, resource: null);
await handler.HandleAsync(ctx);
return ctx.HasSucceeded;
}
/// Kết quả THẬT của endpoint = HỘI 2 vế (ASP.NET Core cộng dồn theo AND).
private static async Task<bool> BridgeAllowedAsync(IdentityFixture fix, User user)
{
var (classPolicy, actionPolicy) = BridgePolicies();
return await AllowsAsync(fix, user, classPolicy)
&& await AllowsAsync(fix, user, actionPolicy);
}
/// Menu row PHẢI có trước Permission: FK `Permission.MenuKey → MenuItem.Key` (Cascade).
private static async Task SeedMenuRowsAsync(TestApplicationDbContext db, params string[] keys)
{
var order = 1;
foreach (var k in keys)
{
if (await db.MenuItems.AnyAsync(m => m.Key == k)) continue;
db.MenuItems.Add(new MenuItem { Key = k, Label = k, Order = order++ });
}
await db.SaveChangesAsync(CancellationToken.None);
}
/// Tạo 1 vai + user của vai đó, cấp đúng bộ cờ được liệt kê.
private static async Task<User> UserWithAsync(
IdentityFixture fix, TestApplicationDbContext db, string roleName,
params (string MenuKey, string Action)[] grants)
{
var user = await fix.CreateUserAsync(
$"{roleName.ToLowerInvariant()}-{Guid.NewGuid():N}@test.local", roleName,
departmentId: null, roles: [roleName]);
var role = await fix.Services.GetRequiredService<RoleManager<Role>>().FindByNameAsync(roleName);
role.Should().NotBeNull();
foreach (var g in grants.GroupBy(x => x.MenuKey))
{
var actions = g.Select(x => x.Action).ToHashSet();
db.Permissions.Add(new Permission
{
Id = Guid.NewGuid(),
RoleId = role!.Id,
MenuKey = g.Key,
CanRead = actions.Contains("Read"),
CanCreate = actions.Contains("Create"),
CanUpdate = actions.Contains("Update"),
CanDelete = actions.Contains("Delete"),
});
}
await db.SaveChangesAsync(CancellationToken.None);
return user;
}
private static (string MenuKey, string Action) Split(string policy)
{
var p = policy.Split('.');
return (p[0], p[1]);
}
// ===================================================================
// (1) ĐỦ 2 VẾ → qua. Đây là ĐỐI CHỨNG DƯƠNG: thiếu nó thì 2 test 403 bên dưới
// vẫn xanh trong một thế giới mà KHÔNG AI vào được cửa này (403 cho tất cả).
// ===================================================================
[Fact]
public async Task CauKhkkSangHd_VaiDuCaHaiVe_ThiQua()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (classPolicy, actionPolicy) = BridgePolicies();
var (khkkKey, khkkAction) = Split(classPolicy);
var (contractKey, contractAction) = Split(actionPolicy);
await SeedMenuRowsAsync(db, khkkKey, contractKey);
var user = await UserWithAsync(fix, db, "BridgeFull",
(khkkKey, khkkAction), (contractKey, contractAction));
(await AllowsAsync(fix, user, classPolicy)).Should().BeTrue();
(await AllowsAsync(fix, user, actionPolicy)).Should().BeTrue();
(await BridgeAllowedAsync(fix, user)).Should().BeTrue(
"vai mang ĐỦ `{0}` ∧ `{1}` phải bấm được nút Đưa vào Hợp đồng", classPolicy, actionPolicy);
}
// ===================================================================
// (2) THIẾU ĐÚNG 1 VẾ (không có `Contracts.Create`) → 403.
// Đây chính là hình dạng của LỖ mà lane BE phải vá bằng seeder: trước S167 KHÔNG vai nào
// mang `Contracts.Create` (`SeedAllRolesReviewReadPermissionsAsync` đặt `CanCreate = isPe` ⇒ false cho key
// `Contracts`) ⇒ mọi người-không-Admin đều rơi vào nhánh này.
// ===================================================================
[Fact]
public async Task CauKhkkSangHd_VaiChiCoQuyenKeHoach_ThiBiChan()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (classPolicy, actionPolicy) = BridgePolicies();
var (khkkKey, khkkAction) = Split(classPolicy);
var (contractKey, _) = Split(actionPolicy);
await SeedMenuRowsAsync(db, khkkKey, contractKey);
// Cấp RỘNG TAY trên module kế hoạch (cả 4 cờ) nhưng KHÔNG có gì trên `Contracts`.
var user = await UserWithAsync(fix, db, "KhkkOnly",
(khkkKey, "Read"), (khkkKey, "Create"), (khkkKey, "Update"), (khkkKey, "Delete"));
(await AllowsAsync(fix, user, classPolicy)).Should().BeTrue(
"vế 1 PHẢI qua — nếu vế này cũng chặn thì test không đo được vế 2 nào cả");
(await AllowsAsync(fix, user, actionPolicy)).Should().BeFalse(
"vế 2 `{0}` là khoá của tài nguyên ĐƯỢC TẠO; quyền trên kế hoạch ký kết " +
"KHÔNG được đẻ ra hợp đồng (gotcha #85 — không có kế-thừa key)", actionPolicy);
(await BridgeAllowedAsync(fix, user)).Should().BeFalse("thiếu 1 vế = 403");
}
// ===================================================================
// (3) THIẾU ĐÚNG 1 VẾ CÒN LẠI (không có `KeHoachKyKet.Read`) → 403.
// Chiều ngược: người được tạo HĐ nhưng ngoài module kế hoạch cũng KHÔNG được lấy dòng
// của phiếu người khác đi bắc cầu.
// ===================================================================
[Fact]
public async Task CauKhkkSangHd_VaiChiCoQuyenTaoHopDong_ThiBiChan()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (classPolicy, actionPolicy) = BridgePolicies();
var (khkkKey, _) = Split(classPolicy);
var (contractKey, contractAction) = Split(actionPolicy);
await SeedMenuRowsAsync(db, khkkKey, contractKey);
var user = await UserWithAsync(fix, db, "ContractOnly", (contractKey, contractAction));
(await AllowsAsync(fix, user, actionPolicy)).Should().BeTrue(
"vế 2 PHẢI qua — chứng minh phép đo không xanh nhờ 'chặn tất');");
(await AllowsAsync(fix, user, classPolicy)).Should().BeFalse();
(await BridgeAllowedAsync(fix, user)).Should().BeFalse("thiếu vế còn lại = vẫn 403");
}
// ===================================================================
// (4) Admin đi cửa bypass — KHÔNG có 1 dòng `Permissions` nào vẫn qua cả 2 vế.
// Khoá hành vi ĐANG CÓ (`MenuPermissionHandler.cs` nhánh `AppRoles.Admin`) để không ai
// "siết cho nhất quán" rồi khoá luôn Admin khỏi cầu.
// ===================================================================
[Fact]
public async Task CauKhkkSangHd_Admin_KhongCanDongQuyenNao_VanQua()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var admin = await fix.CreateUserAsync(
$"admin-{Guid.NewGuid():N}@test.local", "Admin", null, [AppRoles.Admin]);
(await db.Permissions.CountAsync()).Should().Be(0, "cố ý 0 dòng quyền — đo đúng nhánh bypass");
(await BridgeAllowedAsync(fix, admin)).Should().BeTrue();
}
// ===================================================================
// (5) User bị vô hiệu hoá: có ĐỦ quyền nhưng `IsActive = false` → chặn cả 2 vế.
// Vế này bắt lỗi "nghỉ việc nhưng token còn hạn vẫn đẻ được hợp đồng".
// ===================================================================
[Fact]
public async Task CauKhkkSangHd_UserBiVoHieuHoa_ThiChan_DuCoDuQuyen()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (classPolicy, actionPolicy) = BridgePolicies();
var (khkkKey, khkkAction) = Split(classPolicy);
var (contractKey, contractAction) = Split(actionPolicy);
await SeedMenuRowsAsync(db, khkkKey, contractKey);
var user = await UserWithAsync(fix, db, "BridgeDisabled",
(khkkKey, khkkAction), (contractKey, contractAction));
(await BridgeAllowedAsync(fix, user)).Should().BeTrue("chốt trước: lúc còn hoạt động thì QUA");
var tracked = await db.Users.SingleAsync(u => u.Id == user.Id);
tracked.IsActive = false;
await db.SaveChangesAsync(CancellationToken.None);
db.ChangeTracker.Clear();
(await BridgeAllowedAsync(fix, user)).Should().BeFalse(
"đổi ĐÚNG 1 biến `IsActive` ⇒ cùng bộ quyền, kết quả phải ngược lại");
}
}