[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

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:
pqhuy1987
2026-05-13 21:46:51 +07:00
parent a74e671431
commit 40f64c6b32
4 changed files with 96 additions and 10 deletions

View File

@ -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()
{