[CLAUDE] Contract: W4+W6BE guard 4-cửa pin/re-pin nhà chung (khoá cứng theo loại, bridge auto-pin nullable) + Đ2-guard đúng-trạm V2 (V1 giữ nguyên) + test-before security

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-10 19:46:33 +07:00
parent af5f5be837
commit ad9a21ed30
6 changed files with 661 additions and 39 deletions

View File

@ -515,4 +515,256 @@ public class ContractWorkflowServiceApproveV2Tests
contract.Phase.Should().Be(ContractPhase.ChoDuyet, "State unchanged");
}
}
// =====================================================================
// [S187 W6] CỬA ĐÚNG-TRẠM cho TRẢ LẠI / TỪ CHỐI — vá gotcha #82-S156
// =====================================================================
// Lỗ S156: nhánh Reject của `TransitionAsync` chạy TRƯỚC MỌI rào ⇒ ai đăng nhập cũng
// trả lại / từ chối được HĐ bất kỳ, kể cả HĐ đã `DaPhatHanh`. 5 [Fact] dưới đây neo CẢ
// HAI chiều để rào không trượt về một phía:
// • chặn đúng người cần chặn — W6-1 (ngoài quy trình) · W6-2 (đúng người SAI LƯỢT) ·
// W6-3 (HĐ đã phát hành, kể cả người đang tới lượt)
// • KHÔNG chặn người cần cho qua — W6-4 (approver Cấp đang chờ trả lại được thật)
// • KHÔNG lan sang V1 — W6-5 (HĐ legacy giữ hành vi cũ)
// Bỏ W6-4 thì một rào chặn-tất-cả vẫn xanh 4/5; bỏ W6-2 thì rào chỉ-cần-có-tên-trong-quy-
// trình (không xét Cấp) cũng xanh — hai ca đó là phần chịu lực, đừng cắt khi gọn hoá.
//
// Vế "đúng approver duyệt được bình thường" KHÔNG viết lại ở đây: BW1 (:222) đã neo sẵn
// và vẫn xanh sau đợt này — thêm bản sao chỉ là churn.
// Dựng service + trả kèm những thứ test cần assert. Gom lại vì 5 ca dưới lặp y hệt
// 8 dòng wiring của các ca BW phía trên.
private static (ContractWorkflowService svc, TestApplicationDbContext db) WireService(
IdentityFixture fix, Guid actorId, params string[] actorRoles)
{
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var dt = new FixedDateTime(new DateTime(2026, 8, 10, 0, 0, 0, DateTimeKind.Utc));
var currentUser = new TestCurrentUser { UserId = actorId, Roles = actorRoles };
var changelog = new ChangelogService(db, currentUser, um);
var svc = new ContractWorkflowService(
db, new ContractCodeGenerator(db, dt), dt, new NoOpNotificationService(), changelog, um);
return (svc, db);
}
// ---- W6-1: người NGOÀI quy trình bấm Trả lại HĐ V2 đang chờ duyệt → 409, HĐ đứng yên ----
[Fact]
public async Task RejectV2_OutsiderNonAdmin_ThrowsConflict_AndLeavesContractUntouched()
{
var fix = new IdentityFixture();
using (fix)
{
var approver1 = await fix.CreateUserAsync("a1-w61@test.local", "Approver 1 W61",
departmentId: null, roles: new[] { AppRoles.CostControl });
var approver2 = await fix.CreateUserAsync("a2-w61@test.local", "Approver 2 W61",
departmentId: null, roles: new[] { AppRoles.CostControl });
var outsider = await fix.CreateUserAsync("out-w61@test.local", "Outsider W61",
departmentId: null, roles: new[] { AppRoles.CostControl });
var (svc, db) = WireService(fix, outsider.Id, AppRoles.CostControl);
var (wf, _, _, _) = await SeedWorkflowAsync(db, approver1.Id, approver2.Id, code: "QT-CT-W61");
var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "W61", projectCode: "PW61");
var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id,
drafterId: Guid.NewGuid(), levelOrder: 1);
db.Contracts.Add(contract);
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await svc.TransitionAsync(
contract: contract,
targetPhase: ContractPhase.TraLai,
actorUserId: outsider.Id,
actorRoles: new[] { AppRoles.CostControl },
decision: ApprovalDecision.Reject,
comment: "người ngoài thử trả lại",
ct: CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*Không đến lượt bạn*");
contract.Phase.Should().Be(ContractPhase.ChoDuyet, "Rào chặn TRƯỚC khi mutate phase");
contract.CurrentWorkflowStepIndex.Should().Be(0, "Con-trỏ Bước nguyên vẹn");
contract.CurrentApprovalLevelOrder.Should().Be(1, "Con-trỏ Cấp nguyên vẹn");
// Rào ném trước `SaveChangesAsync` ⇒ không được để lại vết nào trong DB.
var changelogs = await db.ContractChangelogs
.Where(c => c.ContractId == contract.Id).ToListAsync();
changelogs.Should().BeEmpty("Trả lại bị chặn thì không ghi lịch sử chuyển trạng thái");
}
}
// ---- W6-2: ĐÚNG người trong quy trình nhưng SAI LƯỢT (approver Cấp 2 khi HĐ đang ở Cấp 1)
// Ca chịu lực: phân biệt rào ĐÚNG-TRẠM với rào chỉ hỏi "có tên trong quy trình không". ----
[Fact]
public async Task RejectV2_ApproverOfLaterLevel_NotYetTheirTurn_ThrowsConflict()
{
var fix = new IdentityFixture();
using (fix)
{
var approver1 = await fix.CreateUserAsync("a1-w62@test.local", "Approver 1 W62",
departmentId: null, roles: new[] { AppRoles.CostControl });
var approver2 = await fix.CreateUserAsync("a2-w62@test.local", "Approver 2 W62",
departmentId: null, roles: new[] { AppRoles.CostControl });
// Actor = approver Cấp 2 — CÓ tên trong quy trình, nhưng HĐ đang đứng ở Cấp 1.
var (svc, db) = WireService(fix, approver2.Id, AppRoles.CostControl);
var (wf, _, _, _) = await SeedWorkflowAsync(db, approver1.Id, approver2.Id, code: "QT-CT-W62");
var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "W62", projectCode: "PW62");
var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id,
drafterId: Guid.NewGuid(), levelOrder: 1);
db.Contracts.Add(contract);
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await svc.TransitionAsync(
contract: contract,
targetPhase: ContractPhase.TraLai,
actorUserId: approver2.Id,
actorRoles: new[] { AppRoles.CostControl },
decision: ApprovalDecision.Reject,
comment: "Cấp 2 nhảy lượt Cấp 1",
ct: CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*Không đến lượt bạn*Cấp 1*");
contract.Phase.Should().Be(ContractPhase.ChoDuyet);
contract.CurrentApprovalLevelOrder.Should().Be(1);
}
}
// ---- W6-3: HĐ ĐÃ PHÁT HÀNH bất khả xâm phạm — kể cả người đang có chân duyệt (lỗ S156) ----
[Fact]
public async Task RejectV2_ContractDaPhatHanh_EvenApproverInWorkflow_ThrowsConflict()
{
var fix = new IdentityFixture();
using (fix)
{
var approver1 = await fix.CreateUserAsync("a1-w63@test.local", "Approver 1 W63",
departmentId: null, roles: new[] { AppRoles.CostControl });
var approver2 = await fix.CreateUserAsync("a2-w63@test.local", "Approver 2 W63",
departmentId: null, roles: new[] { AppRoles.CostControl });
var (svc, db) = WireService(fix, approver1.Id, AppRoles.CostControl);
var (wf, _, _, _) = await SeedWorkflowAsync(db, approver1.Id, approver2.Id, code: "QT-CT-W63");
var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "W63", projectCode: "PW63");
// HĐ đã đi hết quy trình: mã đã gen, con-trỏ đã xoá — đúng hình dạng sau terminal.
var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id,
drafterId: Guid.NewGuid(), levelOrder: 1);
contract.Phase = ContractPhase.DaPhatHanh;
contract.MaHopDong = "PW63/HĐTP/SOL&W63/01";
contract.CurrentWorkflowStepIndex = null;
contract.CurrentApprovalLevelOrder = null;
db.Contracts.Add(contract);
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await svc.TransitionAsync(
contract: contract,
targetPhase: ContractPhase.TuChoi,
actorUserId: approver1.Id,
actorRoles: new[] { AppRoles.CostControl },
decision: ApprovalDecision.Reject,
comment: "thử lật HĐ đã phát hành",
ct: CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*Chỉ từ chối được HĐ đang ở trạng thái Đã gửi duyệt*");
contract.Phase.Should().Be(ContractPhase.DaPhatHanh, "HĐ đã phát hành không bị lật");
contract.MaHopDong.Should().Be("PW63/HĐTP/SOL&W63/01", "Mã HĐ đã cấp giữ nguyên");
}
}
// ---- W6-4: CHIỀU DƯƠNG — approver của Cấp ĐANG CHỜ vẫn trả lại được thật ----
// Không có ca này thì một rào chặn-sạch-mọi-request cũng xanh hết phần còn lại.
[Fact]
public async Task RejectV2_ApproverOfCurrentLevel_ReturnsContractToTraLai()
{
var fix = new IdentityFixture();
using (fix)
{
var approver1 = await fix.CreateUserAsync("a1-w64@test.local", "Approver 1 W64",
departmentId: null, roles: new[] { AppRoles.CostControl });
var approver2 = await fix.CreateUserAsync("a2-w64@test.local", "Approver 2 W64",
departmentId: null, roles: new[] { AppRoles.CostControl });
var (svc, db) = WireService(fix, approver1.Id, AppRoles.CostControl);
var (wf, _, _, _) = await SeedWorkflowAsync(db, approver1.Id, approver2.Id, code: "QT-CT-W64");
var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "W64", projectCode: "PW64");
var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id,
drafterId: Guid.NewGuid(), levelOrder: 1);
db.Contracts.Add(contract);
await db.SaveChangesAsync(CancellationToken.None);
await svc.TransitionAsync(
contract: contract,
targetPhase: ContractPhase.TraLai,
actorUserId: approver1.Id,
actorRoles: new[] { AppRoles.CostControl },
decision: ApprovalDecision.Reject,
comment: "thiếu phụ lục — nhờ bổ sung",
ct: CancellationToken.None);
contract.Phase.Should().Be(ContractPhase.TraLai, "Đúng người đang tới lượt → trả lại được");
contract.CurrentWorkflowStepIndex.Should().BeNull("Trả lại xoá con-trỏ Bước (hành vi sẵn có)");
contract.SlaDeadline.Should().BeNull();
var changelogs = await db.ContractChangelogs
.Where(c => c.ContractId == contract.Id
&& c.EntityType == ChangelogEntityType.Workflow).ToListAsync();
changelogs.Should().Contain(c => c.Summary != null
&& c.Summary.Contains("ChoDuyet") && c.Summary.Contains("TraLai"));
}
}
// ---- W6-5: CONTROL V1 legacy — HĐ không pin V2 giữ nguyên hành vi cũ ----
// 🔴 CỐ Ý chỉ neo ca `ChoDuyet`: đây là bằng chứng "rào W6 không lan sang V1". KHÔNG neo ca
// V1 + `DaPhatHanh` (lỗ S156 phía V1 còn mở theo quyết định owner "chỗ hợp đồng cứ từ
// từ") — viết assert cho một lỗ đang mở là biến nó thành hợp-đồng-hành-vi, để rồi đợt sau
// ai đi vá V1 lại phải cãi nhau với lưới test.
[Fact]
public async Task RejectV1Legacy_NonApprover_StillReturnsToTraLai_BehaviorUnchanged()
{
var fix = new IdentityFixture();
using (fix)
{
var randomUser = await fix.CreateUserAsync("rnd-w65@test.local", "Random W65",
departmentId: null, roles: new[] { AppRoles.CostControl });
var (svc, db) = WireService(fix, randomUser.Id, AppRoles.CostControl);
var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "W65", projectCode: "PW65");
var contract = new Contract
{
Id = Guid.NewGuid(),
Type = ContractType.HopDongThauPhu,
Phase = ContractPhase.ChoDuyet,
SupplierId = sup.Id,
ProjectId = proj.Id,
DrafterUserId = Guid.NewGuid(),
TenHopDong = "HĐ V1 legacy",
GiaTri = 50_000_000m,
ApprovalWorkflowId = null, // ← V1: không pin quy trình V2
WorkflowDefinitionId = null,
CurrentWorkflowStepIndex = 0,
};
db.Contracts.Add(contract);
await db.SaveChangesAsync(CancellationToken.None);
await svc.TransitionAsync(
contract: contract,
targetPhase: ContractPhase.TraLai,
actorUserId: randomUser.Id,
actorRoles: new[] { AppRoles.CostControl },
decision: ApprovalDecision.Reject,
comment: "V1 trả lại như cũ",
ct: CancellationToken.None);
contract.Phase.Should().Be(ContractPhase.TraLai,
"HĐ V1 KHÔNG đi qua cửa đúng-trạm — hành vi legacy giữ nguyên từng byte");
contract.CurrentWorkflowStepIndex.Should().BeNull();
contract.SlaDeadline.Should().BeNull();
}
}
}