[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
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:
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user