[CLAUDE] PE-Workflow: UAT S22+1 — disable cả 3 button khi không quyền + BE guard
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m29s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m29s
User UAT feedback: "Nếu đã không được quyền thao tác thì ko được quyền thao tác hết tất cả các hành động" — trước đây chỉ "Duyệt" disabled, "Trả lại" + "Từ chối" vẫn enabled (design intent S17 cũ). FE 2 app mirror (PeWorkflowPanel.tsx): - `isDisabled = blockedByV2Level` (drop `isForwardApprove &&` qualifier) - Tooltip update "mới thao tác được (Duyệt / Trả lại / Từ chối)" - Comment refresh ghi UAT S22+1 spec + cross-ref BE EnsureCanRejectV2Async BE defense-in-depth (PurchaseEvaluationWorkflowService.cs): - Helper mới `EnsureCanRejectV2Async` mirror FE actorInV2Level logic: Skip silent khi admin/V1/non-ChoDuyet/no actor/no pointer. Throw ForbiddenException khi V2 + ChoDuyet + actor != currentLevel.ApproverUserId. - Invoke ở top Reject branch (cover cả TuChoi + Trả lại sub-branches). - Chặn request forge: non-approver gọi PATCH /transitions direct sẽ 403. Test (test-before §7 — security guard critical algorithm): - ReturnMode tests existing 7/7 vẫn PASS (a2.Id = currentLevel approver, guard accept) - +1 NEW test `Reject_NonApprover_V2_Throws_ForbiddenException` — outsider Drafter role gọi Reject phiếu V2 → throw + Phase không mutate Verify: - dotnet test SolutionErp.slnx — 104/104 PASS (+1 guard regression) Δ: 103 → 104 - npm run build × 2 app — pass (482ms + 583ms) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -334,6 +334,44 @@ public class PurchaseEvaluationWorkflowServiceReturnModeTests
|
||||
}
|
||||
}
|
||||
|
||||
// ============ UAT S22+1: V2 actor scope guard cho Reject ============
|
||||
|
||||
[Fact]
|
||||
public async Task Reject_NonApprover_V2_Throws_ForbiddenException()
|
||||
{
|
||||
// UAT S22+1 — actor không phải approver Cấp hiện tại + V2 pin + non-admin
|
||||
// → BE guard throw ForbiddenException. Mirror FE button disable logic.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (a1, a2) = await SeedApproversAsync(fix, "guard1");
|
||||
// SeedWorkflow Level 2 ApproverUserId = a2. Cấp hiện tại = Level 2.
|
||||
var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id, allowReturnToDrafterL2: true);
|
||||
var pe = BuildPeAtLevel2(wf.Id, drafterId: Guid.NewGuid(), code: "PE-G-V2-001");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// Outsider gọi Reject — không phải a2 (current Level approver).
|
||||
var outsider = await fix.CreateUserAsync(
|
||||
"outsider-guard@test.local", "Outsider Guard", departmentId: null,
|
||||
roles: new[] { AppRoles.CostControl });
|
||||
|
||||
var act = async () => await svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.TraLai,
|
||||
actorUserId: outsider.Id,
|
||||
actorRoles: new[] { AppRoles.CostControl },
|
||||
decision: ApprovalDecision.Reject,
|
||||
comment: "outsider thử forge reject",
|
||||
returnMode: WorkflowReturnMode.Drafter,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ForbiddenException>()
|
||||
.WithMessage("*Không phải lượt bạn*Trả lại / Từ chối*");
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "Guard chặn trước mutate phase");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipToFinal_AdminBypass_Succeeds()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user