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